Skip to content

Commit

Permalink
add closed/open focus traversal; use open on web (#115961)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
yjbanov authored Jan 10, 2023
1 parent d6cd9c0 commit 4205357
Show file tree
Hide file tree
Showing 16 changed files with 655 additions and 73 deletions.
9 changes: 9 additions & 0 deletions packages/flutter/lib/src/material/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,12 @@ Widget _buildMaterialDialogTransitions(BuildContext context, Animation<double> a
///
/// {@macro flutter.widgets.RestorationManager}
///
/// If not null, `traversalEdgeBehavior` argument specifies the transfer of
/// focus beyond the first and the last items of the dialog route. By default,
/// uses [TraversalEdgeBehavior.closedLoop], because it's typical for dialogs
/// to allow users to cycle through widgets inside it without leaving the
/// dialog.
///
/// ** See code in examples/api/lib/material/dialog/show_dialog.2.dart **
/// {@end-tool}
///
Expand All @@ -1263,6 +1269,7 @@ Future<T?> showDialog<T>({
bool useRootNavigator = true,
RouteSettings? routeSettings,
Offset? anchorPoint,
TraversalEdgeBehavior? traversalEdgeBehavior,
}) {
assert(builder != null);
assert(barrierDismissible != null);
Expand All @@ -1289,6 +1296,7 @@ Future<T?> showDialog<T>({
settings: routeSettings,
themes: themes,
anchorPoint: anchorPoint,
traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop,
));
}

Expand Down Expand Up @@ -1367,6 +1375,7 @@ class DialogRoute<T> extends RawDialogRoute<T> {
bool useSafeArea = true,
super.settings,
super.anchorPoint,
super.traversalEdgeBehavior,
}) : assert(barrierDismissible != null),
super(
pageBuilder: (BuildContext buildContext, Animation<double> animation, Animation<double> secondaryAnimation) {
Expand Down
5 changes: 4 additions & 1 deletion packages/flutter/lib/src/material/popup_menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,10 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
required this.capturedThemes,
this.constraints,
required this.clipBehavior,
}) : itemSizes = List<Size?>.filled(items.length, null);
}) : itemSizes = List<Size?>.filled(items.length, null),
// Menus always cycle focus through their items irrespective of the
// focus traversal edge behavior set in the Navigator.
super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop);

final RelativeRect position;
final List<PopupMenuEntry<T>> items;
Expand Down
19 changes: 19 additions & 0 deletions packages/flutter/lib/src/widgets/actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ abstract class Action<T extends Intent> with Diagnosticable {
/// The default implementation returns true.
bool consumesKey(T intent) => true;

/// Converts the result of [invoke] of this action to a [KeyEventResult].
///
/// This is typically used when the action is invoked in response to a keyboard
/// shortcut.
///
/// The [invokeResult] argument is the value returned by the [invoke] method.
///
/// By default, calls [consumesKey] and converts the returned boolean to
/// [KeyEventResult.handled] if it's true, and [KeyEventResult.skipRemainingHandlers]
/// if it's false.
///
/// Concrete implementations may refine the type of [invokeResult], since
/// they know the type returned by [invoke].
KeyEventResult toKeyEventResult(T intent, covariant Object? invokeResult) {
return consumesKey(intent)
? KeyEventResult.handled
: KeyEventResult.skipRemainingHandlers;
}

/// Called when the action is to be performed.
///
/// This is called by the [ActionDispatcher] when an action is invoked via
Expand Down
10 changes: 10 additions & 0 deletions packages/flutter/lib/src/widgets/focus_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,7 @@ class FocusScopeNode extends FocusNode {
super.onKey,
super.skipTraversal,
super.canRequestFocus,
this.traversalEdgeBehavior = TraversalEdgeBehavior.closedLoop,
}) : assert(skipTraversal != null),
assert(canRequestFocus != null),
super(
Expand All @@ -1222,6 +1223,14 @@ class FocusScopeNode extends FocusNode {
@override
FocusScopeNode get nearestScope => this;

/// Controls the transfer of focus beyond the first and the last items of a
/// [FocusScopeNode].
///
/// Changing this field value has no immediate effect on the UI. Instead, next time
/// focus traversal takes place [FocusTraversalPolicy] will read this value
/// and apply the new behavior.
TraversalEdgeBehavior traversalEdgeBehavior;

/// Returns true if this scope is the focused child of its parent scope.
bool get isFirstFocus => enclosingScope!.focusedChild == this;

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

Expand Down
95 changes: 82 additions & 13 deletions packages/flutter/lib/src/widgets/focus_traversal.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,43 @@ enum TraversalDirection {
left,
}

/// An object used to specify a focus traversal policy used for configuring a
/// [FocusTraversalGroup] widget.
/// Controls the transfer of focus beyond the first and the last items of a
/// [FocusScopeNode].
///
/// This enumeration only controls the traversal behavior performed by
/// [FocusTraversalPolicy]. Other methods of focus transfer, such as direct
/// calls to [FocusNode.requestFocus] and [FocusNode.unfocus], are not affected
/// by this enumeration.
///
/// See also:
///
/// * [FocusTraversalPolicy], which implements the logic behind this enum.
/// * [FocusScopeNode], which is configured by this enum.
enum TraversalEdgeBehavior {
/// Keeps the focus among the items of the focus scope.
///
/// Requesting the next focus after the last focusable item will transfer the
/// focus to the first item, and requesting focus previous to the first item
/// will transfer the focus to the last item, thus forming a closed loop of
/// focusable items.
closedLoop,

/// Allows the focus to leave the [FlutterView].
///
/// Requesting next focus after the last focusable item or previous to the
/// first item will unfocus any focused nodes. If the focus traversal action
/// was initiated by the embedder (e.g. the Flutter Engine) the embedder
/// receives a result indicating that the focus is no longer within the
/// current [FlutterView]. For example, [NextFocusAction] invoked via keyboard
/// (typically the TAB key) would receive [KeyEventResult.skipRemainingHandlers]
/// allowing the embedder handle the shortcut. On the web, typically the
/// control is transfered to the browser, allowing the user to reach the
/// address bar, escape an `iframe`, or focus on HTML elements other than
/// those managed by Flutter.
leaveFlutterView,
}

/// Determines how focusable widgets are traversed within a [FocusTraversalGroup].
///
/// The focus traversal policy is what determines which widget is "next",
/// "previous", or in a direction from the widget associated with the currently
Expand Down Expand Up @@ -407,12 +442,24 @@ abstract class FocusTraversalPolicy with Diagnosticable {
return false;
}
if (forward && focusedChild == sortedNodes.last) {
_focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
return true;
switch (nearestScope.traversalEdgeBehavior) {
case TraversalEdgeBehavior.leaveFlutterView:
focusedChild!.unfocus();
return false;
case TraversalEdgeBehavior.closedLoop:
_focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
return true;
}
}
if (!forward && focusedChild == sortedNodes.first) {
_focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
return true;
switch (nearestScope.traversalEdgeBehavior) {
case TraversalEdgeBehavior.leaveFlutterView:
focusedChild!.unfocus();
return false;
case TraversalEdgeBehavior.closedLoop:
_focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
return true;
}
}

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

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

@override
void dispose() {
focusNode?.dispose();
focusNode.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return _FocusTraversalGroupMarker(
policy: widget.policy,
focusNode: focusNode!,
focusNode: focusNode,
child: Focus(
focusNode: focusNode,
canRequestFocus: false,
Expand Down Expand Up @@ -1705,9 +1752,20 @@ class NextFocusIntent extends Intent {
///
/// See [FocusTraversalPolicy] for more information about focus traversal.
class NextFocusAction extends Action<NextFocusIntent> {
/// Attempts to pass the focus to the next widget.
///
/// Returns true if a widget was focused as a result of invoking this action.
///
/// Returns false when the traversal reached the end and the engine must pass
/// focus to platform UI.
@override
bool invoke(NextFocusIntent intent) {
return primaryFocus!.nextFocus();
}

@override
void invoke(NextFocusIntent intent) {
primaryFocus!.nextFocus();
KeyEventResult toKeyEventResult(NextFocusIntent intent, bool invokeResult) {
return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
}
}

Expand All @@ -1729,9 +1787,20 @@ class PreviousFocusIntent extends Intent {
///
/// See [FocusTraversalPolicy] for more information about focus traversal.
class PreviousFocusAction extends Action<PreviousFocusIntent> {
/// Attempts to pass the focus to the previous widget.
///
/// Returns true if a widget was focused as a result of invoking this action.
///
/// Returns false when the traversal reached the beginning and the engine must
/// pass focus to platform UI.
@override
bool invoke(PreviousFocusIntent intent) {
return primaryFocus!.previousFocus();
}

@override
void invoke(PreviousFocusIntent intent) {
primaryFocus!.previousFocus();
KeyEventResult toKeyEventResult(PreviousFocusIntent intent, bool invokeResult) {
return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
}
}

Expand Down
24 changes: 24 additions & 0 deletions packages/flutter/lib/src/widgets/navigator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,13 @@ class DefaultTransitionDelegate<T> extends TransitionDelegate<T> {
}
}

/// The default value of [Navigator.routeTraversalEdgeBehavior].
///
/// {@macro flutter.widgets.navigator.routeTraversalEdgeBehavior}
const TraversalEdgeBehavior kDefaultRouteTraversalEdgeBehavior = kIsWeb
? TraversalEdgeBehavior.leaveFlutterView
: TraversalEdgeBehavior.closedLoop;

/// A widget that manages a set of child widgets with a stack discipline.
///
/// Many apps have a navigator near the top of their widget hierarchy in order
Expand Down Expand Up @@ -1402,10 +1409,12 @@ class Navigator extends StatefulWidget {
this.observers = const <NavigatorObserver>[],
this.requestFocus = true,
this.restorationScopeId,
this.routeTraversalEdgeBehavior = kDefaultRouteTraversalEdgeBehavior,
}) : assert(pages != null),
assert(onGenerateInitialRoutes != null),
assert(transitionDelegate != null),
assert(observers != null),
assert(routeTraversalEdgeBehavior != null),
assert(reportsRouteUpdateToEngine != null);

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

/// Controls the transfer of focus beyond the first and the last items of a
/// focus scope that defines focus traversal of widgets within a route.
///
/// {@template flutter.widgets.navigator.routeTraversalEdgeBehavior}
/// The focus inside routes installed in the top of the app affects how
/// the app behaves with respect to the platform content surrounding it.
/// For example, on the web, an app is at a minimum surrounded by browser UI,
/// such as the address bar, browser tabs, and more. The user should be able
/// to reach browser UI using normal focus shortcuts. Similarly, if the app
/// is embedded within an `<iframe>` or inside a custom element, it should
/// be able to participate in the overall focus traversal, including elements
/// not rendered by Flutter.
/// {@endtemplate}
final TraversalEdgeBehavior routeTraversalEdgeBehavior;

/// The name for the default route of the application.
///
/// See also:
Expand Down
32 changes: 26 additions & 6 deletions packages/flutter/lib/src/widgets/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'basic.dart';
import 'display_feature_sub_screen.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'focus_traversal.dart';
import 'framework.dart';
import 'modal_barrier.dart';
import 'navigator.dart';
Expand Down Expand Up @@ -835,24 +836,34 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
if (widget.route.secondaryAnimation != null) widget.route.secondaryAnimation!,
];
_listenable = Listenable.merge(animations);
if (widget.route.isCurrent && _shouldRequestFocus) {
widget.route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
}
}

@override
void didUpdateWidget(_ModalScope<T> oldWidget) {
super.didUpdateWidget(oldWidget);
assert(widget.route == oldWidget.route);
if (widget.route.isCurrent && _shouldRequestFocus) {
widget.route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
}
_updateFocusScopeNode();
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
_page = null;
_updateFocusScopeNode();
}

void _updateFocusScopeNode() {
final TraversalEdgeBehavior traversalEdgeBehavior;
final ModalRoute<T> route = widget.route;
if (route.traversalEdgeBehavior != null) {
traversalEdgeBehavior = route.traversalEdgeBehavior!;
} else {
traversalEdgeBehavior = route.navigator!.widget.routeTraversalEdgeBehavior;
}
focusScopeNode.traversalEdgeBehavior = traversalEdgeBehavior;
if (route.isCurrent && _shouldRequestFocus) {
route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
}
}

void _forceRebuildPage() {
Expand Down Expand Up @@ -984,6 +995,7 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
ModalRoute({
super.settings,
this.filter,
this.traversalEdgeBehavior,
});

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

/// Controls the transfer of focus beyond the first and the last items of a
/// [FocusScopeNode].
///
/// If set to null, [Navigator.routeTraversalEdgeBehavior] is used.
final TraversalEdgeBehavior? traversalEdgeBehavior;

// The API for general users of this class

/// Returns the modal route most closely associated with the given context.
Expand Down Expand Up @@ -1771,6 +1789,7 @@ abstract class PopupRoute<T> extends ModalRoute<T> {
PopupRoute({
super.settings,
super.filter,
super.traversalEdgeBehavior,
});

@override
Expand Down Expand Up @@ -2018,6 +2037,7 @@ class RawDialogRoute<T> extends PopupRoute<T> {
RouteTransitionsBuilder? transitionBuilder,
super.settings,
this.anchorPoint,
super.traversalEdgeBehavior,
}) : assert(barrierDismissible != null),
_pageBuilder = pageBuilder,
_barrierDismissible = barrierDismissible,
Expand Down
Loading

0 comments on commit 4205357

Please sign in to comment.