Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 4205357

Browse files
authored
add closed/open focus traversal; use open on web (#115961)
* allow focus to leave FlutterView * fix tests and docs * small doc update * fix analysis lint * use closed loop for dialogs * add tests for new API * address comments * test FocusScopeNode.traversalEdgeBehavior setter; reverse wrap-around * rename actionResult to invokeResult * address comments
1 parent d6cd9c0 commit 4205357

16 files changed

+655
-73
lines changed

packages/flutter/lib/src/material/dialog.dart

+9
Original file line numberDiff line numberDiff line change
@@ -1238,6 +1238,12 @@ Widget _buildMaterialDialogTransitions(BuildContext context, Animation<double> a
12381238
///
12391239
/// {@macro flutter.widgets.RestorationManager}
12401240
///
1241+
/// If not null, `traversalEdgeBehavior` argument specifies the transfer of
1242+
/// focus beyond the first and the last items of the dialog route. By default,
1243+
/// uses [TraversalEdgeBehavior.closedLoop], because it's typical for dialogs
1244+
/// to allow users to cycle through widgets inside it without leaving the
1245+
/// dialog.
1246+
///
12411247
/// ** See code in examples/api/lib/material/dialog/show_dialog.2.dart **
12421248
/// {@end-tool}
12431249
///
@@ -1263,6 +1269,7 @@ Future<T?> showDialog<T>({
12631269
bool useRootNavigator = true,
12641270
RouteSettings? routeSettings,
12651271
Offset? anchorPoint,
1272+
TraversalEdgeBehavior? traversalEdgeBehavior,
12661273
}) {
12671274
assert(builder != null);
12681275
assert(barrierDismissible != null);
@@ -1289,6 +1296,7 @@ Future<T?> showDialog<T>({
12891296
settings: routeSettings,
12901297
themes: themes,
12911298
anchorPoint: anchorPoint,
1299+
traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop,
12921300
));
12931301
}
12941302

@@ -1367,6 +1375,7 @@ class DialogRoute<T> extends RawDialogRoute<T> {
13671375
bool useSafeArea = true,
13681376
super.settings,
13691377
super.anchorPoint,
1378+
super.traversalEdgeBehavior,
13701379
}) : assert(barrierDismissible != null),
13711380
super(
13721381
pageBuilder: (BuildContext buildContext, Animation<double> animation, Animation<double> secondaryAnimation) {

packages/flutter/lib/src/material/popup_menu.dart

+4-1
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,10 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
794794
required this.capturedThemes,
795795
this.constraints,
796796
required this.clipBehavior,
797-
}) : itemSizes = List<Size?>.filled(items.length, null);
797+
}) : itemSizes = List<Size?>.filled(items.length, null),
798+
// Menus always cycle focus through their items irrespective of the
799+
// focus traversal edge behavior set in the Navigator.
800+
super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop);
798801

799802
final RelativeRect position;
800803
final List<PopupMenuEntry<T>> items;

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

+19
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,25 @@ abstract class Action<T extends Intent> with Diagnosticable {
251251
/// The default implementation returns true.
252252
bool consumesKey(T intent) => true;
253253

254+
/// Converts the result of [invoke] of this action to a [KeyEventResult].
255+
///
256+
/// This is typically used when the action is invoked in response to a keyboard
257+
/// shortcut.
258+
///
259+
/// The [invokeResult] argument is the value returned by the [invoke] method.
260+
///
261+
/// By default, calls [consumesKey] and converts the returned boolean to
262+
/// [KeyEventResult.handled] if it's true, and [KeyEventResult.skipRemainingHandlers]
263+
/// if it's false.
264+
///
265+
/// Concrete implementations may refine the type of [invokeResult], since
266+
/// they know the type returned by [invoke].
267+
KeyEventResult toKeyEventResult(T intent, covariant Object? invokeResult) {
268+
return consumesKey(intent)
269+
? KeyEventResult.handled
270+
: KeyEventResult.skipRemainingHandlers;
271+
}
272+
254273
/// Called when the action is to be performed.
255274
///
256275
/// This is called by the [ActionDispatcher] when an action is invoked via

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

+10
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,7 @@ class FocusScopeNode extends FocusNode {
12131213
super.onKey,
12141214
super.skipTraversal,
12151215
super.canRequestFocus,
1216+
this.traversalEdgeBehavior = TraversalEdgeBehavior.closedLoop,
12161217
}) : assert(skipTraversal != null),
12171218
assert(canRequestFocus != null),
12181219
super(
@@ -1222,6 +1223,14 @@ class FocusScopeNode extends FocusNode {
12221223
@override
12231224
FocusScopeNode get nearestScope => this;
12241225

1226+
/// Controls the transfer of focus beyond the first and the last items of a
1227+
/// [FocusScopeNode].
1228+
///
1229+
/// Changing this field value has no immediate effect on the UI. Instead, next time
1230+
/// focus traversal takes place [FocusTraversalPolicy] will read this value
1231+
/// and apply the new behavior.
1232+
TraversalEdgeBehavior traversalEdgeBehavior;
1233+
12251234
/// Returns true if this scope is the focused child of its parent scope.
12261235
bool get isFirstFocus => enclosingScope!.focusedChild == this;
12271236

@@ -1349,6 +1358,7 @@ class FocusScopeNode extends FocusNode {
13491358
return child.toStringShort();
13501359
}).toList();
13511360
properties.add(IterableProperty<String>('focusedChildren', childList, defaultValue: const Iterable<String>.empty()));
1361+
properties.add(DiagnosticsProperty<TraversalEdgeBehavior>('traversalEdgeBehavior', traversalEdgeBehavior, defaultValue: TraversalEdgeBehavior.closedLoop));
13521362
}
13531363
}
13541364

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

+82-13
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,43 @@ enum TraversalDirection {
8484
left,
8585
}
8686

87-
/// An object used to specify a focus traversal policy used for configuring a
88-
/// [FocusTraversalGroup] widget.
87+
/// Controls the transfer of focus beyond the first and the last items of a
88+
/// [FocusScopeNode].
89+
///
90+
/// This enumeration only controls the traversal behavior performed by
91+
/// [FocusTraversalPolicy]. Other methods of focus transfer, such as direct
92+
/// calls to [FocusNode.requestFocus] and [FocusNode.unfocus], are not affected
93+
/// by this enumeration.
94+
///
95+
/// See also:
96+
///
97+
/// * [FocusTraversalPolicy], which implements the logic behind this enum.
98+
/// * [FocusScopeNode], which is configured by this enum.
99+
enum TraversalEdgeBehavior {
100+
/// Keeps the focus among the items of the focus scope.
101+
///
102+
/// Requesting the next focus after the last focusable item will transfer the
103+
/// focus to the first item, and requesting focus previous to the first item
104+
/// will transfer the focus to the last item, thus forming a closed loop of
105+
/// focusable items.
106+
closedLoop,
107+
108+
/// Allows the focus to leave the [FlutterView].
109+
///
110+
/// Requesting next focus after the last focusable item or previous to the
111+
/// first item will unfocus any focused nodes. If the focus traversal action
112+
/// was initiated by the embedder (e.g. the Flutter Engine) the embedder
113+
/// receives a result indicating that the focus is no longer within the
114+
/// current [FlutterView]. For example, [NextFocusAction] invoked via keyboard
115+
/// (typically the TAB key) would receive [KeyEventResult.skipRemainingHandlers]
116+
/// allowing the embedder handle the shortcut. On the web, typically the
117+
/// control is transfered to the browser, allowing the user to reach the
118+
/// address bar, escape an `iframe`, or focus on HTML elements other than
119+
/// those managed by Flutter.
120+
leaveFlutterView,
121+
}
122+
123+
/// Determines how focusable widgets are traversed within a [FocusTraversalGroup].
89124
///
90125
/// The focus traversal policy is what determines which widget is "next",
91126
/// "previous", or in a direction from the widget associated with the currently
@@ -407,12 +442,24 @@ abstract class FocusTraversalPolicy with Diagnosticable {
407442
return false;
408443
}
409444
if (forward && focusedChild == sortedNodes.last) {
410-
_focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
411-
return true;
445+
switch (nearestScope.traversalEdgeBehavior) {
446+
case TraversalEdgeBehavior.leaveFlutterView:
447+
focusedChild!.unfocus();
448+
return false;
449+
case TraversalEdgeBehavior.closedLoop:
450+
_focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
451+
return true;
452+
}
412453
}
413454
if (!forward && focusedChild == sortedNodes.first) {
414-
_focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
415-
return true;
455+
switch (nearestScope.traversalEdgeBehavior) {
456+
case TraversalEdgeBehavior.leaveFlutterView:
457+
focusedChild!.unfocus();
458+
return false;
459+
case TraversalEdgeBehavior.closedLoop:
460+
_focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
461+
return true;
462+
}
416463
}
417464

418465
final Iterable<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
@@ -1592,7 +1639,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
15921639
// The internal focus node used to collect the children of this node into a
15931640
// group, and to provide a context for the traversal algorithm to sort the
15941641
// group with.
1595-
FocusNode? focusNode;
1642+
late final FocusNode focusNode;
15961643

15971644
@override
15981645
void initState() {
@@ -1606,15 +1653,15 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
16061653

16071654
@override
16081655
void dispose() {
1609-
focusNode?.dispose();
1656+
focusNode.dispose();
16101657
super.dispose();
16111658
}
16121659

16131660
@override
16141661
Widget build(BuildContext context) {
16151662
return _FocusTraversalGroupMarker(
16161663
policy: widget.policy,
1617-
focusNode: focusNode!,
1664+
focusNode: focusNode,
16181665
child: Focus(
16191666
focusNode: focusNode,
16201667
canRequestFocus: false,
@@ -1705,9 +1752,20 @@ class NextFocusIntent extends Intent {
17051752
///
17061753
/// See [FocusTraversalPolicy] for more information about focus traversal.
17071754
class NextFocusAction extends Action<NextFocusIntent> {
1755+
/// Attempts to pass the focus to the next widget.
1756+
///
1757+
/// Returns true if a widget was focused as a result of invoking this action.
1758+
///
1759+
/// Returns false when the traversal reached the end and the engine must pass
1760+
/// focus to platform UI.
1761+
@override
1762+
bool invoke(NextFocusIntent intent) {
1763+
return primaryFocus!.nextFocus();
1764+
}
1765+
17081766
@override
1709-
void invoke(NextFocusIntent intent) {
1710-
primaryFocus!.nextFocus();
1767+
KeyEventResult toKeyEventResult(NextFocusIntent intent, bool invokeResult) {
1768+
return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
17111769
}
17121770
}
17131771

@@ -1729,9 +1787,20 @@ class PreviousFocusIntent extends Intent {
17291787
///
17301788
/// See [FocusTraversalPolicy] for more information about focus traversal.
17311789
class PreviousFocusAction extends Action<PreviousFocusIntent> {
1790+
/// Attempts to pass the focus to the previous widget.
1791+
///
1792+
/// Returns true if a widget was focused as a result of invoking this action.
1793+
///
1794+
/// Returns false when the traversal reached the beginning and the engine must
1795+
/// pass focus to platform UI.
1796+
@override
1797+
bool invoke(PreviousFocusIntent intent) {
1798+
return primaryFocus!.previousFocus();
1799+
}
1800+
17321801
@override
1733-
void invoke(PreviousFocusIntent intent) {
1734-
primaryFocus!.previousFocus();
1802+
KeyEventResult toKeyEventResult(PreviousFocusIntent intent, bool invokeResult) {
1803+
return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
17351804
}
17361805
}
17371806

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

+24
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,13 @@ class DefaultTransitionDelegate<T> extends TransitionDelegate<T> {
10931093
}
10941094
}
10951095

1096+
/// The default value of [Navigator.routeTraversalEdgeBehavior].
1097+
///
1098+
/// {@macro flutter.widgets.navigator.routeTraversalEdgeBehavior}
1099+
const TraversalEdgeBehavior kDefaultRouteTraversalEdgeBehavior = kIsWeb
1100+
? TraversalEdgeBehavior.leaveFlutterView
1101+
: TraversalEdgeBehavior.closedLoop;
1102+
10961103
/// A widget that manages a set of child widgets with a stack discipline.
10971104
///
10981105
/// Many apps have a navigator near the top of their widget hierarchy in order
@@ -1402,10 +1409,12 @@ class Navigator extends StatefulWidget {
14021409
this.observers = const <NavigatorObserver>[],
14031410
this.requestFocus = true,
14041411
this.restorationScopeId,
1412+
this.routeTraversalEdgeBehavior = kDefaultRouteTraversalEdgeBehavior,
14051413
}) : assert(pages != null),
14061414
assert(onGenerateInitialRoutes != null),
14071415
assert(transitionDelegate != null),
14081416
assert(observers != null),
1417+
assert(routeTraversalEdgeBehavior != null),
14091418
assert(reportsRouteUpdateToEngine != null);
14101419

14111420
/// The list of pages with which to populate the history.
@@ -1513,6 +1522,21 @@ class Navigator extends StatefulWidget {
15131522
/// {@endtemplate}
15141523
final String? restorationScopeId;
15151524

1525+
/// Controls the transfer of focus beyond the first and the last items of a
1526+
/// focus scope that defines focus traversal of widgets within a route.
1527+
///
1528+
/// {@template flutter.widgets.navigator.routeTraversalEdgeBehavior}
1529+
/// The focus inside routes installed in the top of the app affects how
1530+
/// the app behaves with respect to the platform content surrounding it.
1531+
/// For example, on the web, an app is at a minimum surrounded by browser UI,
1532+
/// such as the address bar, browser tabs, and more. The user should be able
1533+
/// to reach browser UI using normal focus shortcuts. Similarly, if the app
1534+
/// is embedded within an `<iframe>` or inside a custom element, it should
1535+
/// be able to participate in the overall focus traversal, including elements
1536+
/// not rendered by Flutter.
1537+
/// {@endtemplate}
1538+
final TraversalEdgeBehavior routeTraversalEdgeBehavior;
1539+
15161540
/// The name for the default route of the application.
15171541
///
15181542
/// See also:

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

+26-6
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'basic.dart';
1515
import 'display_feature_sub_screen.dart';
1616
import 'focus_manager.dart';
1717
import 'focus_scope.dart';
18+
import 'focus_traversal.dart';
1819
import 'framework.dart';
1920
import 'modal_barrier.dart';
2021
import 'navigator.dart';
@@ -835,24 +836,34 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
835836
if (widget.route.secondaryAnimation != null) widget.route.secondaryAnimation!,
836837
];
837838
_listenable = Listenable.merge(animations);
838-
if (widget.route.isCurrent && _shouldRequestFocus) {
839-
widget.route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
840-
}
841839
}
842840

843841
@override
844842
void didUpdateWidget(_ModalScope<T> oldWidget) {
845843
super.didUpdateWidget(oldWidget);
846844
assert(widget.route == oldWidget.route);
847-
if (widget.route.isCurrent && _shouldRequestFocus) {
848-
widget.route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
849-
}
845+
_updateFocusScopeNode();
850846
}
851847

852848
@override
853849
void didChangeDependencies() {
854850
super.didChangeDependencies();
855851
_page = null;
852+
_updateFocusScopeNode();
853+
}
854+
855+
void _updateFocusScopeNode() {
856+
final TraversalEdgeBehavior traversalEdgeBehavior;
857+
final ModalRoute<T> route = widget.route;
858+
if (route.traversalEdgeBehavior != null) {
859+
traversalEdgeBehavior = route.traversalEdgeBehavior!;
860+
} else {
861+
traversalEdgeBehavior = route.navigator!.widget.routeTraversalEdgeBehavior;
862+
}
863+
focusScopeNode.traversalEdgeBehavior = traversalEdgeBehavior;
864+
if (route.isCurrent && _shouldRequestFocus) {
865+
route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
866+
}
856867
}
857868

858869
void _forceRebuildPage() {
@@ -984,6 +995,7 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
984995
ModalRoute({
985996
super.settings,
986997
this.filter,
998+
this.traversalEdgeBehavior,
987999
});
9881000

9891001
/// The filter to add to the barrier.
@@ -992,6 +1004,12 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
9921004
/// [BackdropFilter]. This allows blur effects, for example.
9931005
final ui.ImageFilter? filter;
9941006

1007+
/// Controls the transfer of focus beyond the first and the last items of a
1008+
/// [FocusScopeNode].
1009+
///
1010+
/// If set to null, [Navigator.routeTraversalEdgeBehavior] is used.
1011+
final TraversalEdgeBehavior? traversalEdgeBehavior;
1012+
9951013
// The API for general users of this class
9961014

9971015
/// Returns the modal route most closely associated with the given context.
@@ -1771,6 +1789,7 @@ abstract class PopupRoute<T> extends ModalRoute<T> {
17711789
PopupRoute({
17721790
super.settings,
17731791
super.filter,
1792+
super.traversalEdgeBehavior,
17741793
});
17751794

17761795
@override
@@ -2018,6 +2037,7 @@ class RawDialogRoute<T> extends PopupRoute<T> {
20182037
RouteTransitionsBuilder? transitionBuilder,
20192038
super.settings,
20202039
this.anchorPoint,
2040+
super.traversalEdgeBehavior,
20212041
}) : assert(barrierDismissible != null),
20222042
_pageBuilder = pageBuilder,
20232043
_barrierDismissible = barrierDismissible,

0 commit comments

Comments
 (0)