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
This commit is contained in:
parent
d6cd9c0cef
commit
4205357554
@ -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}
|
||||
///
|
||||
@ -1263,6 +1269,7 @@ Future<T?> showDialog<T>({
|
||||
bool useRootNavigator = true,
|
||||
RouteSettings? routeSettings,
|
||||
Offset? anchorPoint,
|
||||
TraversalEdgeBehavior? traversalEdgeBehavior,
|
||||
}) {
|
||||
assert(builder != null);
|
||||
assert(barrierDismissible != null);
|
||||
@ -1289,6 +1296,7 @@ Future<T?> showDialog<T>({
|
||||
settings: routeSettings,
|
||||
themes: themes,
|
||||
anchorPoint: anchorPoint,
|
||||
traversalEdgeBehavior: traversalEdgeBehavior ?? TraversalEdgeBehavior.closedLoop,
|
||||
));
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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<String>('focusedChildren', childList, defaultValue: const Iterable<String>.empty()));
|
||||
properties.add(DiagnosticsProperty<TraversalEdgeBehavior>('traversalEdgeBehavior', traversalEdgeBehavior, defaultValue: TraversalEdgeBehavior.closedLoop));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
|
||||
@ -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() {
|
||||
@ -1606,7 +1653,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode?.dispose();
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -1614,7 +1661,7 @@ class _FocusTraversalGroupState extends State<FocusTraversalGroup> {
|
||||
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<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
|
||||
void invoke(NextFocusIntent intent) {
|
||||
primaryFocus!.nextFocus();
|
||||
bool invoke(NextFocusIntent intent) {
|
||||
return primaryFocus!.nextFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
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<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
|
||||
void invoke(PreviousFocusIntent intent) {
|
||||
primaryFocus!.previousFocus();
|
||||
bool invoke(PreviousFocusIntent intent) {
|
||||
return primaryFocus!.previousFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
KeyEventResult toKeyEventResult(PreviousFocusIntent intent, bool invokeResult) {
|
||||
return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
@ -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.
|
||||
@ -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:
|
||||
|
@ -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';
|
||||
@ -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() {
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -1771,6 +1789,7 @@ abstract class PopupRoute<T> extends ModalRoute<T> {
|
||||
PopupRoute({
|
||||
super.settings,
|
||||
super.filter,
|
||||
super.traversalEdgeBehavior,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -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,
|
||||
|
@ -851,10 +851,8 @@ class ShortcutManager with Diagnosticable, ChangeNotifier {
|
||||
intent: matchedIntent,
|
||||
);
|
||||
if (action != null && action.isEnabled(matchedIntent)) {
|
||||
Actions.of(primaryContext).invokeAction(action, matchedIntent, primaryContext);
|
||||
return action.consumesKey(matchedIntent)
|
||||
? KeyEventResult.handled
|
||||
: KeyEventResult.skipRemainingHandlers;
|
||||
final Object? invokeResult = Actions.of(primaryContext).invokeAction(action, matchedIntent, primaryContext);
|
||||
return action.toKeyEventResult(matchedIntent, invokeResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
int routeBuildCount = 0;
|
||||
int dependentBuildCount = 0;
|
||||
|
@ -11,7 +11,12 @@ import 'package:flutter_test/flutter_test.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(
|
||||
theme: theme,
|
||||
home: Material(
|
||||
@ -23,6 +28,7 @@ MaterialApp _buildAppWithDialog(Widget dialog, { ThemeData? theme, double textSc
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
traversalEdgeBehavior: traversalEdgeBehavior,
|
||||
builder: (BuildContext context) {
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
|
||||
@ -2582,6 +2588,123 @@ void main() {
|
||||
expect(tester.getTopLeft(find.byKey(actionKey)).dx, (800 - 20) / 2);
|
||||
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 {
|
||||
|
@ -137,20 +137,22 @@ void main() {
|
||||
final FocusNode focusNode2 = FocusNode(debugLabel: 'InputChip 2');
|
||||
await tester.pumpWidget(
|
||||
wrapForChip(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
InputChip(
|
||||
focusNode: focusNode1,
|
||||
autofocus: true,
|
||||
label: const Text('Chip A'),
|
||||
onPressed: () { },
|
||||
),
|
||||
InputChip(
|
||||
focusNode: focusNode2,
|
||||
autofocus: true,
|
||||
label: const Text('Chip B'),
|
||||
),
|
||||
],
|
||||
child: FocusScope(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
InputChip(
|
||||
focusNode: focusNode1,
|
||||
autofocus: true,
|
||||
label: const Text('Chip A'),
|
||||
onPressed: () { },
|
||||
),
|
||||
InputChip(
|
||||
focusNode: focusNode2,
|
||||
autofocus: true,
|
||||
label: const Text('Chip B'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -3024,6 +3024,80 @@ void main() {
|
||||
material = tester.widget<Material>(find.byType(Material).last);
|
||||
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 {
|
||||
|
@ -369,28 +369,30 @@ void main() {
|
||||
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 focusNode2 = FocusNode(debugLabel: '$RawMaterialButton 2');
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
RawMaterialButton(
|
||||
autofocus: true,
|
||||
focusNode: focusNode1,
|
||||
onPressed: () {},
|
||||
child: Container(width: 100, height: 100, color: const Color(0xffff0000)),
|
||||
),
|
||||
RawMaterialButton(
|
||||
autofocus: true,
|
||||
focusNode: focusNode2,
|
||||
onPressed: null,
|
||||
child: Container(width: 100, height: 100, color: const Color(0xffff0000)),
|
||||
),
|
||||
],
|
||||
home: FocusScope(
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
RawMaterialButton(
|
||||
autofocus: true,
|
||||
focusNode: focusNode1,
|
||||
onPressed: () {},
|
||||
child: Container(width: 100, height: 100, color: const Color(0xffff0000)),
|
||||
),
|
||||
RawMaterialButton(
|
||||
autofocus: true,
|
||||
focusNode: focusNode2,
|
||||
onPressed: null,
|
||||
child: Container(width: 100, height: 100, color: const Color(0xffff0000)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -5996,27 +5996,29 @@ void main() {
|
||||
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 focusNode2 = FocusNode(debugLabel: 'TextField 2');
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Material(
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
focusNode: focusNode1,
|
||||
autofocus: true,
|
||||
maxLength: 10,
|
||||
enabled: true,
|
||||
),
|
||||
TextField(
|
||||
focusNode: focusNode2,
|
||||
maxLength: 10,
|
||||
enabled: false,
|
||||
),
|
||||
],
|
||||
child: FocusScope(
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
focusNode: focusNode1,
|
||||
autofocus: true,
|
||||
maxLength: 10,
|
||||
enabled: true,
|
||||
),
|
||||
TextField(
|
||||
focusNode: focusNode2,
|
||||
maxLength: 10,
|
||||
enabled: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -1093,6 +1093,16 @@ void main() {
|
||||
action.invoke(intent);
|
||||
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', () {
|
||||
@ -2000,3 +2010,21 @@ class RedirectOutputAction extends LogInvocationAction {
|
||||
@override
|
||||
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 @@
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@ -2447,6 +2448,204 @@ void main() {
|
||||
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> {
|
||||
|
Loading…
x
Reference in New Issue
Block a user