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
///
/// {@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
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<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;
}
}
......
......@@ -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> {
......
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