diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart index e21330412124..77952798675e 100644 --- a/packages/flutter/lib/src/material/dialog.dart +++ b/packages/flutter/lib/src/material/dialog.dart @@ -1238,6 +1238,12 @@ Widget _buildMaterialDialogTransitions(BuildContext context, Animation 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} /// @@ -1263,6 +1269,7 @@ Future showDialog({ bool useRootNavigator = true, RouteSettings? routeSettings, Offset? anchorPoint, + TraversalEdgeBehavior? traversalEdgeBehavior, }) { assert(builder != null); assert(barrierDismissible != null); @@ -1289,6 +1296,7 @@ Future showDialog({ settings: routeSettings, themes: themes, anchorPoint: anchorPoint, + traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop, )); } @@ -1367,6 +1375,7 @@ class DialogRoute extends RawDialogRoute { bool useSafeArea = true, super.settings, super.anchorPoint, + super.traversalEdgeBehavior, }) : assert(barrierDismissible != null), super( pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index 26c3a5efe712..e27dc3d161c7 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -794,7 +794,10 @@ class _PopupMenuRoute extends PopupRoute { required this.capturedThemes, this.constraints, required this.clipBehavior, - }) : itemSizes = List.filled(items.length, null); + }) : itemSizes = List.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> items; diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart index 4af2204c6b3a..8cd8dfc209d0 100644 --- a/packages/flutter/lib/src/widgets/actions.dart +++ b/packages/flutter/lib/src/widgets/actions.dart @@ -251,6 +251,25 @@ abstract class Action 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 diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index e32e8bf2fdd9..329f2e649d15 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -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( @@ -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; @@ -1349,6 +1358,7 @@ class FocusScopeNode extends FocusNode { return child.toStringShort(); }).toList(); properties.add(IterableProperty('focusedChildren', childList, defaultValue: const Iterable.empty())); + properties.add(DiagnosticsProperty('traversalEdgeBehavior', traversalEdgeBehavior, defaultValue: TraversalEdgeBehavior.closedLoop)); } } diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 3eb2dddaa359..af7f3cd2cea7 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -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 @@ -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 maybeFlipped = forward ? sortedNodes : sortedNodes.reversed; @@ -1592,7 +1639,7 @@ class _FocusTraversalGroupState extends State { // 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() { @@ -1606,7 +1653,7 @@ class _FocusTraversalGroupState extends State { @override void dispose() { - focusNode?.dispose(); + focusNode.dispose(); super.dispose(); } @@ -1614,7 +1661,7 @@ class _FocusTraversalGroupState extends State { Widget build(BuildContext context) { return _FocusTraversalGroupMarker( policy: widget.policy, - focusNode: focusNode!, + focusNode: focusNode, child: Focus( focusNode: focusNode, canRequestFocus: false, @@ -1705,9 +1752,20 @@ class NextFocusIntent extends Intent { /// /// See [FocusTraversalPolicy] for more information about focus traversal. class NextFocusAction extends Action { + /// 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; } } @@ -1729,9 +1787,20 @@ class PreviousFocusIntent extends Intent { /// /// See [FocusTraversalPolicy] for more information about focus traversal. class PreviousFocusAction extends Action { + /// 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; } } diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index 8a855ed87b1d..a17382045f58 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -1093,6 +1093,13 @@ class DefaultTransitionDelegate extends TransitionDelegate { } } +/// 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 @@ -1402,10 +1409,12 @@ class Navigator extends StatefulWidget { this.observers = const [], 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. @@ -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 `