Unverified Commit 42053575 authored by Yegor's avatar Yegor Committed by GitHub

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
parent d6cd9c0c
...@@ -1238,6 +1238,12 @@ Widget _buildMaterialDialogTransitions(BuildContext context, Animation<double> a ...@@ -1238,6 +1238,12 @@ Widget _buildMaterialDialogTransitions(BuildContext context, Animation<double> a
/// ///
/// {@macro flutter.widgets.RestorationManager} /// {@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 ** /// ** See code in examples/api/lib/material/dialog/show_dialog.2.dart **
/// {@end-tool} /// {@end-tool}
/// ///
...@@ -1263,6 +1269,7 @@ Future<T?> showDialog<T>({ ...@@ -1263,6 +1269,7 @@ Future<T?> showDialog<T>({
bool useRootNavigator = true, bool useRootNavigator = true,
RouteSettings? routeSettings, RouteSettings? routeSettings,
Offset? anchorPoint, Offset? anchorPoint,
TraversalEdgeBehavior? traversalEdgeBehavior,
}) { }) {
assert(builder != null); assert(builder != null);
assert(barrierDismissible != null); assert(barrierDismissible != null);
...@@ -1289,6 +1296,7 @@ Future<T?> showDialog<T>({ ...@@ -1289,6 +1296,7 @@ Future<T?> showDialog<T>({
settings: routeSettings, settings: routeSettings,
themes: themes, themes: themes,
anchorPoint: anchorPoint, anchorPoint: anchorPoint,
traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop,
)); ));
} }
...@@ -1367,6 +1375,7 @@ class DialogRoute<T> extends RawDialogRoute<T> { ...@@ -1367,6 +1375,7 @@ class DialogRoute<T> extends RawDialogRoute<T> {
bool useSafeArea = true, bool useSafeArea = true,
super.settings, super.settings,
super.anchorPoint, super.anchorPoint,
super.traversalEdgeBehavior,
}) : assert(barrierDismissible != null), }) : assert(barrierDismissible != null),
super( super(
pageBuilder: (BuildContext buildContext, Animation<double> animation, Animation<double> secondaryAnimation) { pageBuilder: (BuildContext buildContext, Animation<double> animation, Animation<double> secondaryAnimation) {
......
...@@ -794,7 +794,10 @@ class _PopupMenuRoute<T> extends PopupRoute<T> { ...@@ -794,7 +794,10 @@ class _PopupMenuRoute<T> extends PopupRoute<T> {
required this.capturedThemes, required this.capturedThemes,
this.constraints, this.constraints,
required this.clipBehavior, 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 RelativeRect position;
final List<PopupMenuEntry<T>> items; final List<PopupMenuEntry<T>> items;
......
...@@ -251,6 +251,25 @@ abstract class Action<T extends Intent> with Diagnosticable { ...@@ -251,6 +251,25 @@ abstract class Action<T extends Intent> with Diagnosticable {
/// The default implementation returns true. /// The default implementation returns true.
bool consumesKey(T intent) => 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. /// Called when the action is to be performed.
/// ///
/// This is called by the [ActionDispatcher] when an action is invoked via /// This is called by the [ActionDispatcher] when an action is invoked via
......
...@@ -1213,6 +1213,7 @@ class FocusScopeNode extends FocusNode { ...@@ -1213,6 +1213,7 @@ class FocusScopeNode extends FocusNode {
super.onKey, super.onKey,
super.skipTraversal, super.skipTraversal,
super.canRequestFocus, super.canRequestFocus,
this.traversalEdgeBehavior = TraversalEdgeBehavior.closedLoop,
}) : assert(skipTraversal != null), }) : assert(skipTraversal != null),
assert(canRequestFocus != null), assert(canRequestFocus != null),
super( super(
...@@ -1222,6 +1223,14 @@ class FocusScopeNode extends FocusNode { ...@@ -1222,6 +1223,14 @@ class FocusScopeNode extends FocusNode {
@override @override
FocusScopeNode get nearestScope => this; 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. /// Returns true if this scope is the focused child of its parent scope.
bool get isFirstFocus => enclosingScope!.focusedChild == this; bool get isFirstFocus => enclosingScope!.focusedChild == this;
...@@ -1349,6 +1358,7 @@ class FocusScopeNode extends FocusNode { ...@@ -1349,6 +1358,7 @@ class FocusScopeNode extends FocusNode {
return child.toStringShort(); return child.toStringShort();
}).toList(); }).toList();
properties.add(IterableProperty<String>('focusedChildren', childList, defaultValue: const Iterable<String>.empty())); properties.add(IterableProperty<String>('focusedChildren', childList, defaultValue: const Iterable<String>.empty()));
properties.add(DiagnosticsProperty<TraversalEdgeBehavior>('traversalEdgeBehavior', traversalEdgeBehavior, defaultValue: TraversalEdgeBehavior.closedLoop));
} }
} }
......
...@@ -84,8 +84,43 @@ enum TraversalDirection { ...@@ -84,8 +84,43 @@ enum TraversalDirection {
left, left,
} }
/// An object used to specify a focus traversal policy used for configuring a /// Controls the transfer of focus beyond the first and the last items of a
/// [FocusTraversalGroup] widget. /// [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", /// The focus traversal policy is what determines which widget is "next",
/// "previous", or in a direction from the widget associated with the currently /// "previous", or in a direction from the widget associated with the currently
...@@ -407,12 +442,24 @@ abstract class FocusTraversalPolicy with Diagnosticable { ...@@ -407,12 +442,24 @@ abstract class FocusTraversalPolicy with Diagnosticable {
return false; return false;
} }
if (forward && focusedChild == sortedNodes.last) { if (forward && focusedChild == sortedNodes.last) {
_focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd); switch (nearestScope.traversalEdgeBehavior) {
return true; case TraversalEdgeBehavior.leaveFlutterView:
focusedChild!.unfocus();
return false;
case TraversalEdgeBehavior.closedLoop:
_focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
return true;
}
} }
if (!forward && focusedChild == sortedNodes.first) { if (!forward && focusedChild == sortedNodes.first) {
_focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart); switch (nearestScope.traversalEdgeBehavior) {
return true; 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; final Iterable<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
...@@ -1592,7 +1639,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> { ...@@ -1592,7 +1639,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
// The internal focus node used to collect the children of this node into a // 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, and to provide a context for the traversal algorithm to sort the
// group with. // group with.
FocusNode? focusNode; late final FocusNode focusNode;
@override @override
void initState() { void initState() {
...@@ -1606,7 +1653,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> { ...@@ -1606,7 +1653,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
@override @override
void dispose() { void dispose() {
focusNode?.dispose(); focusNode.dispose();
super.dispose(); super.dispose();
} }
...@@ -1614,7 +1661,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> { ...@@ -1614,7 +1661,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _FocusTraversalGroupMarker( return _FocusTraversalGroupMarker(
policy: widget.policy, policy: widget.policy,
focusNode: focusNode!, focusNode: focusNode,
child: Focus( child: Focus(
focusNode: focusNode, focusNode: focusNode,
canRequestFocus: false, canRequestFocus: false,
...@@ -1705,9 +1752,20 @@ class NextFocusIntent extends Intent { ...@@ -1705,9 +1752,20 @@ class NextFocusIntent extends Intent {
/// ///
/// See [FocusTraversalPolicy] for more information about focus traversal. /// See [FocusTraversalPolicy] for more information about focus traversal.
class NextFocusAction extends Action<NextFocusIntent> { 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 @override
void invoke(NextFocusIntent intent) { KeyEventResult toKeyEventResult(NextFocusIntent intent, bool invokeResult) {
primaryFocus!.nextFocus(); return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
} }
} }
...@@ -1729,9 +1787,20 @@ class PreviousFocusIntent extends Intent { ...@@ -1729,9 +1787,20 @@ class PreviousFocusIntent extends Intent {
/// ///
/// See [FocusTraversalPolicy] for more information about focus traversal. /// See [FocusTraversalPolicy] for more information about focus traversal.
class PreviousFocusAction extends Action<PreviousFocusIntent> { 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 @override
void invoke(PreviousFocusIntent intent) { KeyEventResult toKeyEventResult(PreviousFocusIntent intent, bool invokeResult) {
primaryFocus!.previousFocus(); return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
} }
} }
......
...@@ -1093,6 +1093,13 @@ class DefaultTransitionDelegate<T> extends TransitionDelegate<T> { ...@@ -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. /// 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 /// Many apps have a navigator near the top of their widget hierarchy in order
...@@ -1402,10 +1409,12 @@ class Navigator extends StatefulWidget { ...@@ -1402,10 +1409,12 @@ class Navigator extends StatefulWidget {
this.observers = const <NavigatorObserver>[], this.observers = const <NavigatorObserver>[],
this.requestFocus = true, this.requestFocus = true,
this.restorationScopeId, this.restorationScopeId,
this.routeTraversalEdgeBehavior = kDefaultRouteTraversalEdgeBehavior,
}) : assert(pages != null), }) : assert(pages != null),
assert(onGenerateInitialRoutes != null), assert(onGenerateInitialRoutes != null),
assert(transitionDelegate != null), assert(transitionDelegate != null),
assert(observers != null), assert(observers != null),
assert(routeTraversalEdgeBehavior != null),
assert(reportsRouteUpdateToEngine != null); assert(reportsRouteUpdateToEngine != null);
/// The list of pages with which to populate the history. /// The list of pages with which to populate the history.
...@@ -1513,6 +1522,21 @@ class Navigator extends StatefulWidget { ...@@ -1513,6 +1522,21 @@ class Navigator extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final String? restorationScopeId; 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. /// The name for the default route of the application.
/// ///
/// See also: /// See also:
......
...@@ -15,6 +15,7 @@ import 'basic.dart'; ...@@ -15,6 +15,7 @@ import 'basic.dart';
import 'display_feature_sub_screen.dart'; import 'display_feature_sub_screen.dart';
import 'focus_manager.dart'; import 'focus_manager.dart';
import 'focus_scope.dart'; import 'focus_scope.dart';
import 'focus_traversal.dart';
import 'framework.dart'; import 'framework.dart';
import 'modal_barrier.dart'; import 'modal_barrier.dart';
import 'navigator.dart'; import 'navigator.dart';
...@@ -835,24 +836,34 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> { ...@@ -835,24 +836,34 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
if (widget.route.secondaryAnimation != null) widget.route.secondaryAnimation!, if (widget.route.secondaryAnimation != null) widget.route.secondaryAnimation!,
]; ];
_listenable = Listenable.merge(animations); _listenable = Listenable.merge(animations);
if (widget.route.isCurrent && _shouldRequestFocus) {
widget.route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
}
} }
@override @override
void didUpdateWidget(_ModalScope<T> oldWidget) { void didUpdateWidget(_ModalScope<T> oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
assert(widget.route == oldWidget.route); assert(widget.route == oldWidget.route);
if (widget.route.isCurrent && _shouldRequestFocus) { _updateFocusScopeNode();
widget.route.navigator!.focusNode.enclosingScope?.setFirstFocus(focusScopeNode);
}
} }
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_page = null; _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() { void _forceRebuildPage() {
...@@ -984,6 +995,7 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T ...@@ -984,6 +995,7 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
ModalRoute({ ModalRoute({
super.settings, super.settings,
this.filter, this.filter,
this.traversalEdgeBehavior,
}); });
/// The filter to add to the barrier. /// The filter to add to the barrier.
...@@ -992,6 +1004,12 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T ...@@ -992,6 +1004,12 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// [BackdropFilter]. This allows blur effects, for example. /// [BackdropFilter]. This allows blur effects, for example.
final ui.ImageFilter? filter; 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 // The API for general users of this class
/// Returns the modal route most closely associated with the given context. /// Returns the modal route most closely associated with the given context.
...@@ -1771,6 +1789,7 @@ abstract class PopupRoute<T> extends ModalRoute<T> { ...@@ -1771,6 +1789,7 @@ abstract class PopupRoute<T> extends ModalRoute<T> {
PopupRoute({ PopupRoute({
super.settings, super.settings,
super.filter, super.filter,
super.traversalEdgeBehavior,
}); });
@override @override
...@@ -2018,6 +2037,7 @@ class RawDialogRoute<T> extends PopupRoute<T> { ...@@ -2018,6 +2037,7 @@ class RawDialogRoute<T> extends PopupRoute<T> {
RouteTransitionsBuilder? transitionBuilder, RouteTransitionsBuilder? transitionBuilder,
super.settings, super.settings,
this.anchorPoint, this.anchorPoint,
super.traversalEdgeBehavior,
}) : assert(barrierDismissible != null), }) : assert(barrierDismissible != null),
_pageBuilder = pageBuilder, _pageBuilder = pageBuilder,
_barrierDismissible = barrierDismissible, _barrierDismissible = barrierDismissible,
......
...@@ -851,10 +851,8 @@ class ShortcutManager with Diagnosticable, ChangeNotifier { ...@@ -851,10 +851,8 @@ class ShortcutManager with Diagnosticable, ChangeNotifier {
intent: matchedIntent, intent: matchedIntent,
); );
if (action != null && action.isEnabled(matchedIntent)) { if (action != null && action.isEnabled(matchedIntent)) {
Actions.of(primaryContext).invokeAction(action, matchedIntent, primaryContext); final Object? invokeResult = Actions.of(primaryContext).invokeAction(action, matchedIntent, primaryContext);
return action.consumesKey(matchedIntent) return action.toKeyEventResult(matchedIntent, invokeResult);
? KeyEventResult.handled
: KeyEventResult.skipRemainingHandlers;
} }
} }
} }
......
...@@ -411,7 +411,7 @@ void main() { ...@@ -411,7 +411,7 @@ void main() {
); );
}); });
testWidgets("WidgetsApp don't rebuild routes when MediaQuery updates", (WidgetTester tester) async { testWidgets("WidgetsApp doesn't rebuild routes when MediaQuery updates", (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/37878 // Regression test for https://github.com/flutter/flutter/issues/37878
int routeBuildCount = 0; int routeBuildCount = 0;
int dependentBuildCount = 0; int dependentBuildCount = 0;
......
...@@ -11,7 +11,12 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -11,7 +11,12 @@ import 'package:flutter_test/flutter_test.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
MaterialApp _buildAppWithDialog(Widget dialog, { ThemeData? theme, double textScaleFactor = 1.0 }) { MaterialApp _buildAppWithDialog(
Widget dialog, {
ThemeData? theme,
double textScaleFactor = 1.0,
TraversalEdgeBehavior? traversalEdgeBehavior,
}) {
return MaterialApp( return MaterialApp(
theme: theme, theme: theme,
home: Material( home: Material(
...@@ -23,6 +28,7 @@ MaterialApp _buildAppWithDialog(Widget dialog, { ThemeData? theme, double textSc ...@@ -23,6 +28,7 @@ MaterialApp _buildAppWithDialog(Widget dialog, { ThemeData? theme, double textSc
onPressed: () { onPressed: () {
showDialog<void>( showDialog<void>(
context: context, context: context,
traversalEdgeBehavior: traversalEdgeBehavior,
builder: (BuildContext context) { builder: (BuildContext context) {
return MediaQuery( return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor), data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
...@@ -2582,6 +2588,123 @@ void main() { ...@@ -2582,6 +2588,123 @@ void main() {
expect(tester.getTopLeft(find.byKey(actionKey)).dx, (800 - 20) / 2); expect(tester.getTopLeft(find.byKey(actionKey)).dx, (800 - 20) / 2);
expect(tester.getTopRight(find.byKey(actionKey)).dx, (800 - 20) / 2 + 20); expect(tester.getTopRight(find.byKey(actionKey)).dx, (800 - 20) / 2 + 20);
}); });
testWidgets('Uses closed loop focus traversal', (WidgetTester tester) async {
final FocusNode okNode = FocusNode();
final FocusNode cancelNode = FocusNode();
Future<bool> nextFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const NextFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
Future<bool> previousFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const PreviousFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
final AlertDialog dialog = AlertDialog(
content: const Text('Test dialog'),
actions: <Widget>[
TextButton(
focusNode: okNode,
onPressed: () {},
child: const Text('OK'),
),
TextButton(
focusNode: cancelNode,
onPressed: () {},
child: const Text('Cancel'),
),
],
);
await tester.pumpWidget(_buildAppWithDialog(dialog));
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
// Start at OK
okNode.requestFocus();
await tester.pump();
expect(okNode.hasFocus, true);
expect(cancelNode.hasFocus, false);
// OK -> Cancel
expect(await nextFocus(), true);
expect(okNode.hasFocus, false);
expect(cancelNode.hasFocus, true);
// Cancel -> OK
expect(await nextFocus(), true);
expect(okNode.hasFocus, true);
expect(cancelNode.hasFocus, false);
// Cancel <- OK
expect(await previousFocus(), true);
expect(okNode.hasFocus, false);
expect(cancelNode.hasFocus, true);
// OK <- Cancel
expect(await previousFocus(), true);
expect(okNode.hasFocus, true);
expect(cancelNode.hasFocus, false);
});
testWidgets('Uses open focus traversal when overridden', (WidgetTester tester) async {
final FocusNode okNode = FocusNode();
final FocusNode cancelNode = FocusNode();
Future<bool> nextFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const NextFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
final AlertDialog dialog = AlertDialog(
content: const Text('Test dialog'),
actions: <Widget>[
TextButton(
focusNode: okNode,
onPressed: () {},
child: const Text('OK'),
),
TextButton(
focusNode: cancelNode,
onPressed: () {},
child: const Text('Cancel'),
),
],
);
await tester.pumpWidget(_buildAppWithDialog(dialog, traversalEdgeBehavior: TraversalEdgeBehavior.leaveFlutterView));
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
// Start at OK
okNode.requestFocus();
await tester.pump();
expect(okNode.hasFocus, true);
expect(cancelNode.hasFocus, false);
// OK -> Cancel
expect(await nextFocus(), true);
expect(okNode.hasFocus, false);
expect(cancelNode.hasFocus, true);
// Cancel -> nothing
expect(await nextFocus(), false);
expect(okNode.hasFocus, false);
expect(cancelNode.hasFocus, false);
});
} }
class _RestorableDialogTestWidget extends StatelessWidget { class _RestorableDialogTestWidget extends StatelessWidget {
......
...@@ -137,20 +137,22 @@ void main() { ...@@ -137,20 +137,22 @@ void main() {
final FocusNode focusNode2 = FocusNode(debugLabel: 'InputChip 2'); final FocusNode focusNode2 = FocusNode(debugLabel: 'InputChip 2');
await tester.pumpWidget( await tester.pumpWidget(
wrapForChip( wrapForChip(
child: Column( child: FocusScope(
children: <Widget>[ child: Column(
InputChip( children: <Widget>[
focusNode: focusNode1, InputChip(
autofocus: true, focusNode: focusNode1,
label: const Text('Chip A'), autofocus: true,
onPressed: () { }, label: const Text('Chip A'),
), onPressed: () { },
InputChip( ),
focusNode: focusNode2, InputChip(
autofocus: true, focusNode: focusNode2,
label: const Text('Chip B'), autofocus: true,
), label: const Text('Chip B'),
], ),
],
),
), ),
), ),
); );
......
...@@ -3024,6 +3024,80 @@ void main() { ...@@ -3024,6 +3024,80 @@ void main() {
material = tester.widget<Material>(find.byType(Material).last); material = tester.widget<Material>(find.byType(Material).last);
expect(material.clipBehavior, Clip.hardEdge); expect(material.clipBehavior, Clip.hardEdge);
}); });
testWidgets('Uses closed loop focus traversal', (WidgetTester tester) async {
FocusNode nodeA() => Focus.of(find.text('A').evaluate().single);
FocusNode nodeB() => Focus.of(find.text('B').evaluate().single);
final GlobalKey popupButtonKey = GlobalKey();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Center(
child: PopupMenuButton<String>(
key: popupButtonKey,
itemBuilder: (_) => const <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'a',
child: Text('A'),
),
PopupMenuItem<String>(
value: 'b',
child: Text('B'),
),
],
),
),
),
));
// Open the popup to build and show the menu contents.
await tester.tap(find.byKey(popupButtonKey));
await tester.pumpAndSettle();
Future<bool> nextFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const NextFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
Future<bool> previousFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const PreviousFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
// Start at A
nodeA().requestFocus();
await tester.pump();
expect(nodeA().hasFocus, true);
expect(nodeB().hasFocus, false);
// A -> B
expect(await nextFocus(), true);
expect(nodeA().hasFocus, false);
expect(nodeB().hasFocus, true);
// B -> A (wrap around)
expect(await nextFocus(), true);
expect(nodeA().hasFocus, true);
expect(nodeB().hasFocus, false);
// B <- A
expect(await previousFocus(), true);
expect(nodeA().hasFocus, false);
expect(nodeB().hasFocus, true);
// A <- B (wrap around)
expect(await previousFocus(), true);
expect(nodeA().hasFocus, true);
expect(nodeB().hasFocus, false);
});
} }
class TestApp extends StatelessWidget { class TestApp extends StatelessWidget {
......
...@@ -369,28 +369,30 @@ void main() { ...@@ -369,28 +369,30 @@ void main() {
expect(focusNode.hasPrimaryFocus, isFalse); expect(focusNode.hasPrimaryFocus, isFalse);
}); });
testWidgets("Disabled RawMaterialButton can't be traversed to when disabled.", (WidgetTester tester) async { testWidgets("Disabled RawMaterialButton can't be traversed to.", (WidgetTester tester) async {
final FocusNode focusNode1 = FocusNode(debugLabel: '$RawMaterialButton 1'); final FocusNode focusNode1 = FocusNode(debugLabel: '$RawMaterialButton 1');
final FocusNode focusNode2 = FocusNode(debugLabel: '$RawMaterialButton 2'); final FocusNode focusNode2 = FocusNode(debugLabel: '$RawMaterialButton 2');
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Center( home: FocusScope(
child: Column( child: Center(
children: <Widget>[ child: Column(
RawMaterialButton( children: <Widget>[
autofocus: true, RawMaterialButton(
focusNode: focusNode1, autofocus: true,
onPressed: () {}, focusNode: focusNode1,
child: Container(width: 100, height: 100, color: const Color(0xffff0000)), onPressed: () {},
), child: Container(width: 100, height: 100, color: const Color(0xffff0000)),
RawMaterialButton( ),
autofocus: true, RawMaterialButton(
focusNode: focusNode2, autofocus: true,
onPressed: null, focusNode: focusNode2,
child: Container(width: 100, height: 100, color: const Color(0xffff0000)), onPressed: null,
), child: Container(width: 100, height: 100, color: const Color(0xffff0000)),
], ),
],
),
), ),
), ),
), ),
......
...@@ -5996,27 +5996,29 @@ void main() { ...@@ -5996,27 +5996,29 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets("Disabled TextField can't be traversed to when disabled.", (WidgetTester tester) async { testWidgets("Disabled TextField can't be traversed to.", (WidgetTester tester) async {
final FocusNode focusNode1 = FocusNode(debugLabel: 'TextField 1'); final FocusNode focusNode1 = FocusNode(debugLabel: 'TextField 1');
final FocusNode focusNode2 = FocusNode(debugLabel: 'TextField 2'); final FocusNode focusNode2 = FocusNode(debugLabel: 'TextField 2');
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Material( home: Material(
child: Center( child: FocusScope(
child: Column( child: Center(
children: <Widget>[ child: Column(
TextField( children: <Widget>[
focusNode: focusNode1, TextField(
autofocus: true, focusNode: focusNode1,
maxLength: 10, autofocus: true,
enabled: true, maxLength: 10,
), enabled: true,
TextField( ),
focusNode: focusNode2, TextField(
maxLength: 10, focusNode: focusNode2,
enabled: false, maxLength: 10,
), enabled: false,
], ),
],
),
), ),
), ),
), ),
......
...@@ -1093,6 +1093,16 @@ void main() { ...@@ -1093,6 +1093,16 @@ void main() {
action.invoke(intent); action.invoke(intent);
expect(called, isTrue); expect(called, isTrue);
}); });
testWidgets('Base Action class default toKeyEventResult delegates to consumesKey', (WidgetTester tester) async {
expect(
DefaultToKeyEventResultAction(consumesKey: false).toKeyEventResult(const DefaultToKeyEventResultIntent(), null),
KeyEventResult.skipRemainingHandlers,
);
expect(
DefaultToKeyEventResultAction(consumesKey: true).toKeyEventResult(const DefaultToKeyEventResultIntent(), null),
KeyEventResult.handled,
);
});
}); });
group('Diagnostics', () { group('Diagnostics', () {
...@@ -2000,3 +2010,21 @@ class RedirectOutputAction extends LogInvocationAction { ...@@ -2000,3 +2010,21 @@ class RedirectOutputAction extends LogInvocationAction {
@override @override
void invoke(LogIntent intent) => super.invoke(LogIntent(log: newLog)); void invoke(LogIntent intent) => super.invoke(LogIntent(log: newLog));
} }
class DefaultToKeyEventResultIntent extends Intent {
const DefaultToKeyEventResultIntent();
}
class DefaultToKeyEventResultAction extends Action<DefaultToKeyEventResultIntent> {
DefaultToKeyEventResultAction({
required bool consumesKey
}) : _consumesKey = consumesKey;
final bool _consumesKey;
@override
bool consumesKey(DefaultToKeyEventResultIntent intent) => _consumesKey;
@override
void invoke(DefaultToKeyEventResultIntent intent) {}
}
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -2447,6 +2448,204 @@ void main() { ...@@ -2447,6 +2448,204 @@ void main() {
expect(semantics, hasSemantics(expectedSemantics)); expect(semantics, hasSemantics(expectedSemantics));
}); });
}); });
// Tests that Flutter allows the focus to escape the app. This is the default
// behavior on the web, since on the web the app is always embedded into some
// surrounding UI. There's at least the browser UI for the address bar and
// tabs. If Flutter Web is embedded into a custom element, there could be
// other focusable HTML elements surrounding Flutter.
//
// See also: https://github.com/flutter/flutter/issues/114463
testWidgets('Default route edge traversal behavior', (WidgetTester tester) async {
final FocusNode nodeA = FocusNode();
final FocusNode nodeB = FocusNode();
Future<bool> nextFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const NextFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
Future<bool> previousFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const PreviousFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
await tester.pumpWidget(
MaterialApp(
home: Column(
children: <Widget>[
TextButton(
focusNode: nodeA,
child: const Text('A'),
onPressed: () {},
),
TextButton(
focusNode: nodeB,
child: const Text('B'),
onPressed: () {},
),
],
),
),
);
nodeA.requestFocus();
await tester.pump();
expect(nodeA.hasFocus, true);
expect(nodeB.hasFocus, false);
// A -> B
expect(await nextFocus(), isTrue);
expect(nodeA.hasFocus, false);
expect(nodeB.hasFocus, true);
// A <- B
expect(await previousFocus(), isTrue);
expect(nodeA.hasFocus, true);
expect(nodeB.hasFocus, false);
// A -> B
expect(await nextFocus(), isTrue);
expect(nodeA.hasFocus, false);
expect(nodeB.hasFocus, true);
// B ->
// * on mobile: cycle back to A
// * on web: let the focus escape the app
expect(await nextFocus(), !kIsWeb);
expect(nodeA.hasFocus, !kIsWeb);
expect(nodeB.hasFocus, false);
// Start with A again, but wrap around in the opposite direction
nodeA.requestFocus();
await tester.pump();
expect(await previousFocus(), !kIsWeb);
expect(nodeA.hasFocus, false);
expect(nodeB.hasFocus, !kIsWeb);
});
// This test creates a FocusScopeNode configured to traverse focus in a closed
// loop. After traversing one loop, it changes the behavior to leave the
// FlutterView, then verifies that the new behavior did indeed take effect.
testWidgets('FocusScopeNode.traversalEdgeBehavior takes effect after update', (WidgetTester tester) async {
final FocusScopeNode scope = FocusScopeNode();
expect(scope.traversalEdgeBehavior, TraversalEdgeBehavior.closedLoop);
final FocusNode nodeA = FocusNode();
final FocusNode nodeB = FocusNode();
Future<bool> nextFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const NextFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
Future<bool> previousFocus() async {
final bool result = Actions.invoke(
primaryFocus!.context!,
const PreviousFocusIntent(),
)! as bool;
await tester.pump();
return result;
}
await tester.pumpWidget(
MaterialApp(
home: Focus(
focusNode: scope,
child: Column(
children: <Widget>[
TextButton(
focusNode: nodeA,
child: const Text('A'),
onPressed: () {},
),
TextButton(
focusNode: nodeB,
child: const Text('B'),
onPressed: () {},
),
],
),
),
),
);
nodeA.requestFocus();
await tester.pump();
expect(nodeA.hasFocus, true);
expect(nodeB.hasFocus, false);
// A -> B
expect(await nextFocus(), isTrue);
expect(nodeA.hasFocus, false);
expect(nodeB.hasFocus, true);
// A <- B (wrap around)
expect(await nextFocus(), isTrue);
expect(nodeA.hasFocus, true);
expect(nodeB.hasFocus, false);
// Change the behavior and verify that the new behavior is in effect.
scope.traversalEdgeBehavior = TraversalEdgeBehavior.leaveFlutterView;
expect(scope.traversalEdgeBehavior, TraversalEdgeBehavior.leaveFlutterView);
// A -> B
expect(await nextFocus(), isTrue);
expect(nodeA.hasFocus, false);
expect(nodeB.hasFocus, true);
// B -> escape the view
expect(await nextFocus(), false);
expect(nodeA.hasFocus, false);
expect(nodeB.hasFocus, false);
// Change the behavior back to closedLoop and verify it's in effect. Also,
// this time traverse in the opposite direction.
nodeA.requestFocus();
await tester.pump();
expect(nodeA.hasFocus, true);
scope.traversalEdgeBehavior = TraversalEdgeBehavior.closedLoop;
expect(scope.traversalEdgeBehavior, TraversalEdgeBehavior.closedLoop);
expect(await previousFocus(), true);
expect(nodeA.hasFocus, false);
expect(nodeB.hasFocus, true);
});
testWidgets('NextFocusAction converts invoke result to KeyEventResult', (WidgetTester tester) async {
expect(
NextFocusAction().toKeyEventResult(const NextFocusIntent(), true),
KeyEventResult.handled,
);
expect(
NextFocusAction().toKeyEventResult(const NextFocusIntent(), false),
KeyEventResult.skipRemainingHandlers,
);
});
testWidgets('PreviousFocusAction converts invoke result to KeyEventResult', (WidgetTester tester) async {
expect(
PreviousFocusAction().toKeyEventResult(const PreviousFocusIntent(), true),
KeyEventResult.handled,
);
expect(
PreviousFocusAction().toKeyEventResult(const PreviousFocusIntent(), false),
KeyEventResult.skipRemainingHandlers,
);
});
} }
class TestRoute extends PageRouteBuilder<void> { class TestRoute extends PageRouteBuilder<void> {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment