diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 02a512ff077f..33a83b0b7a5b 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -35,13 +35,15 @@ BuildContext? _getAncestor(BuildContext context, {int count = 1}) { return target; } -void _focusAndEnsureVisible( - FocusNode node, { - ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, -}) { - node.requestFocus(); - Scrollable.ensureVisible(node.context!, alignment: 1.0, alignmentPolicy: alignmentPolicy); -} +/// Signature for the callback that's called when a traversal policy +/// requests focus. +typedef TraversalRequestFocusCallback = void Function( + FocusNode node, { + ScrollPositionAlignmentPolicy? alignmentPolicy, + double? alignment, + Duration? duration, + Curve? curve, +}); // A class to temporarily hold information about FocusTraversalGroups when // sorting their contents. @@ -150,7 +152,39 @@ enum TraversalEdgeBehavior { abstract class FocusTraversalPolicy with Diagnosticable { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. - const FocusTraversalPolicy(); + /// + /// {@template flutter.widgets.FocusTraversalPolicy.requestFocusCallback} + /// The `requestFocusCallback` can be used to override the default behavior + /// of the focus requests. If `requestFocusCallback` + /// is null, it defaults to [FocusTraversalPolicy.defaultTraversalRequestFocusCallback]. + /// {@endtemplate} + const FocusTraversalPolicy({ + TraversalRequestFocusCallback? requestFocusCallback + }) : requestFocusCallback = requestFocusCallback ?? defaultTraversalRequestFocusCallback; + + /// The callback used to move the focus from one focus node to another when + /// traversing them using a keyboard. By default it requests focus on the next + /// node and ensures the node is visible if it's in a scrollable. + final TraversalRequestFocusCallback requestFocusCallback; + + /// The default value for [requestFocusCallback]. + /// Requests focus from `node` and ensures the node is visible + /// by calling [Scrollable.ensureVisible]. + static void defaultTraversalRequestFocusCallback( + FocusNode node, { + ScrollPositionAlignmentPolicy? alignmentPolicy, + double? alignment, + Duration? duration, + Curve? curve, + }) { + node.requestFocus(); + Scrollable.ensureVisible( + node.context!, alignment: alignment ?? 1.0, + alignmentPolicy: alignmentPolicy ?? ScrollPositionAlignmentPolicy.explicit, + duration: duration ?? Duration.zero, + curve: curve ?? Curves.ease, + ); + } /// Returns the node that should receive focus if focus is traversing /// forwards, and there is no current focus. @@ -423,7 +457,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { if (focusedChild == null) { final FocusNode? firstFocus = forward ? findFirstFocus(currentNode) : findLastFocus(currentNode); if (firstFocus != null) { - _focusAndEnsureVisible( + requestFocusCallback( firstFocus, alignmentPolicy: forward ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd : ScrollPositionAlignmentPolicy.keepVisibleAtStart, ); @@ -442,7 +476,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { focusedChild!.unfocus(); return false; case TraversalEdgeBehavior.closedLoop: - _focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd); + requestFocusCallback(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd); return true; } } @@ -452,7 +486,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { focusedChild!.unfocus(); return false; case TraversalEdgeBehavior.closedLoop: - _focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart); + requestFocusCallback(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart); return true; } } @@ -461,7 +495,7 @@ abstract class FocusTraversalPolicy with Diagnosticable { FocusNode? previousNode; for (final FocusNode node in maybeFlipped) { if (previousNode == focusedChild) { - _focusAndEnsureVisible( + requestFocusCallback( node, alignmentPolicy: forward ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd : ScrollPositionAlignmentPolicy.keepVisibleAtStart, ); @@ -771,7 +805,7 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { case TraversalDirection.down: alignmentPolicy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd; } - _focusAndEnsureVisible( + requestFocusCallback( lastNode, alignmentPolicy: alignmentPolicy, ); @@ -850,13 +884,13 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { switch (direction) { case TraversalDirection.up: case TraversalDirection.left: - _focusAndEnsureVisible( + requestFocusCallback( firstFocus, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, ); case TraversalDirection.right: case TraversalDirection.down: - _focusAndEnsureVisible( + requestFocusCallback( firstFocus, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, ); @@ -927,13 +961,13 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { switch (direction) { case TraversalDirection.up: case TraversalDirection.left: - _focusAndEnsureVisible( + requestFocusCallback( found, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, ); case TraversalDirection.down: case TraversalDirection.right: - _focusAndEnsureVisible( + requestFocusCallback( found, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, ); @@ -962,6 +996,11 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { /// * [OrderedTraversalPolicy], a policy that describes the order /// explicitly using [FocusTraversalOrder] widgets. class WidgetOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin { + /// Constructs a traversal policy that orders widgets for keyboard traversal + /// based on the widget hierarchy order. + /// + /// {@macro flutter.widgets.FocusTraversalPolicy.requestFocusCallback} + WidgetOrderTraversalPolicy({super.requestFocusCallback}); @override Iterable sortDescendants(Iterable descendants, FocusNode currentNode) => descendants; } @@ -1129,6 +1168,11 @@ class _ReadingOrderDirectionalGroupData with Diagnosticable { /// * [OrderedTraversalPolicy], a policy that describes the order /// explicitly using [FocusTraversalOrder] widgets. class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusTraversalPolicyMixin { + /// Constructs a traversal policy that orders the widgets in "reading order". + /// + /// {@macro flutter.widgets.FocusTraversalPolicy.requestFocusCallback} + ReadingOrderTraversalPolicy({super.requestFocusCallback}); + // Collects the given candidates into groups by directionality. The candidates // have already been sorted as if they all had the directionality of the // nearest Directionality ancestor. @@ -1418,7 +1462,7 @@ class OrderedTraversalPolicy extends FocusTraversalPolicy with DirectionalFocusT /// based on an explicit order. /// /// If [secondary] is null, it will default to [ReadingOrderTraversalPolicy]. - OrderedTraversalPolicy({this.secondary}); + OrderedTraversalPolicy({this.secondary, super.requestFocusCallback}); /// This is the policy that is used when a node doesn't have an order /// assigned, or when multiple nodes have orders which are identical. @@ -1770,8 +1814,16 @@ class _FocusTraversalGroupState extends State { class RequestFocusIntent extends Intent { /// Creates an intent used with [RequestFocusAction]. /// - /// The argument must not be null. - const RequestFocusIntent(this.focusNode); + /// The [focusNode] argument must not be null. + /// {@macro flutter.widgets.FocusTraversalPolicy.requestFocusCallback} + const RequestFocusIntent(this.focusNode, { + TraversalRequestFocusCallback? requestFocusCallback + }) : requestFocusCallback = requestFocusCallback ?? FocusTraversalPolicy.defaultTraversalRequestFocusCallback; + + /// The callback used to move the focus to the node [focusNode]. + /// By default it requests focus on the node and ensures the node is visible + /// if it's in a scrollable. + final TraversalRequestFocusCallback requestFocusCallback; /// The [FocusNode] that is to be focused. final FocusNode focusNode; @@ -1802,9 +1854,10 @@ class RequestFocusIntent extends Intent { /// /// See [FocusTraversalPolicy] for more information about focus traversal. class RequestFocusAction extends Action { + @override void invoke(RequestFocusIntent intent) { - _focusAndEnsureVisible(intent.focusNode); + intent.requestFocusCallback(intent.focusNode); } } diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart index 62efd05efd4d..905771bcddaa 100644 --- a/packages/flutter/test/widgets/focus_traversal_test.dart +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -385,6 +385,49 @@ void main() { expect(firstFocusNode.hasFocus, isTrue); expect(scope.hasFocus, isTrue); }); + + testWidgets('Custom requestFocusCallback gets called on the next/previous focus.', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(debugLabel: '1'); + final FocusNode testNode1 = FocusNode(debugLabel: 'Focus Node'); + bool calledCallback = false; + + await tester.pumpWidget( + FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy( + requestFocusCallback: (FocusNode node, {double? alignment, + ScrollPositionAlignmentPolicy? alignmentPolicy, + Curve? curve, + Duration? duration}) { + calledCallback = true; + }, + ), + child: FocusScope( + debugLabel: 'key1', + child: Focus( + key: key1, + focusNode: testNode1, + child: Container(), + ), + ), + ), + ); + + final Element element = tester.element(find.byKey(key1)); + final FocusNode scope = FocusScope.of(element); + scope.nextFocus(); + + await tester.pump(); + + expect(calledCallback, isTrue); + + calledCallback = false; + + scope.previousFocus(); + await tester.pump(); + + expect(calledCallback, isTrue); + }); + }); group(ReadingOrderTraversalPolicy, () { @@ -824,6 +867,51 @@ void main() { } expect(order, orderedEquals([1, 2, 3, 4, 5, 6, 7, 8, 9, 0])); }); + + testWidgets('Custom requestFocusCallback gets called on the next/previous focus.', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(debugLabel: '1'); + final FocusNode testNode1 = FocusNode(debugLabel: 'Focus Node'); + bool calledCallback = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy( + requestFocusCallback: (FocusNode node, {double? alignment, + ScrollPositionAlignmentPolicy? alignmentPolicy, + Curve? curve, + Duration? duration}) { + calledCallback = true; + }, + ), + child: FocusScope( + debugLabel: 'key1', + child: Focus( + key: key1, + focusNode: testNode1, + child: Container(), + ), + ), + ), + ), + ); + + final Element element = tester.element(find.byKey(key1)); + final FocusNode scope = FocusScope.of(element); + scope.nextFocus(); + + await tester.pump(); + + expect(calledCallback, isTrue); + + calledCallback = false; + + scope.previousFocus(); + await tester.pump(); + + expect(calledCallback, isTrue); + }); }); group(OrderedTraversalPolicy, () { @@ -1188,6 +1276,51 @@ void main() { expect(firstFocusNode.hasFocus, isTrue); expect(scope.hasFocus, isTrue); }); + + testWidgets('Custom requestFocusCallback gets called on the next/previous focus.', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(debugLabel: '1'); + final FocusNode testNode1 = FocusNode(debugLabel: 'Focus Node'); + bool calledCallback = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy( + requestFocusCallback: (FocusNode node, {double? alignment, + ScrollPositionAlignmentPolicy? alignmentPolicy, + Curve? curve, + Duration? duration}) { + calledCallback = true; + }, + ), + child: FocusScope( + debugLabel: 'key1', + child: Focus( + key: key1, + focusNode: testNode1, + child: Container(), + ), + ), + ), + ), + ); + + final Element element = tester.element(find.byKey(key1)); + final FocusNode scope = FocusScope.of(element); + scope.nextFocus(); + + await tester.pump(); + + expect(calledCallback, isTrue); + + calledCallback = false; + + scope.previousFocus(); + await tester.pump(); + + expect(calledCallback, isTrue); + }); }); group(DirectionalFocusTraversalPolicyMixin, () { @@ -2324,6 +2457,60 @@ void main() { expect(events.length, 2); }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('Custom requestFocusCallback gets called on focusInDirection up/down/left/right.', (WidgetTester tester) async { + final GlobalKey key1 = GlobalKey(debugLabel: '1'); + final FocusNode testNode1 = FocusNode(debugLabel: 'Focus Node'); + bool calledCallback = false; + + await tester.pumpWidget( + FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy( + requestFocusCallback: (FocusNode node, {double? alignment, + ScrollPositionAlignmentPolicy? alignmentPolicy, + Curve? curve, + Duration? duration}) { + calledCallback = true; + }, + ), + child: FocusScope( + debugLabel: 'key1', + child: Focus( + key: key1, + focusNode: testNode1, + child: Container(), + ), + ), + ), + ); + + final Element element = tester.element(find.byKey(key1)); + final FocusNode scope = FocusScope.of(element); + scope.focusInDirection(TraversalDirection.up); + + await tester.pump(); + + expect(calledCallback, isTrue); + + calledCallback = false; + + scope.focusInDirection(TraversalDirection.down); + await tester.pump(); + + expect(calledCallback, isTrue); + + calledCallback = false; + + scope.focusInDirection(TraversalDirection.left); + await tester.pump(); + + expect(calledCallback, isTrue); + + scope.focusInDirection(TraversalDirection.right); + await tester.pump(); + + expect(calledCallback, isTrue); + }); }); group(FocusTraversalGroup, () { @@ -2865,6 +3052,42 @@ void main() { KeyEventResult.skipRemainingHandlers, ); }); + + testWidgets('RequestFocusAction calls the RequestFocusIntent.requestFocusCallback', (WidgetTester tester) async { + bool calledCallback = false; + final FocusNode nodeA = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: TextButton( + focusNode: nodeA, + child: const Text('A'), + onPressed: () {}, + ), + ) + ) + ); + + RequestFocusAction().invoke(RequestFocusIntent(nodeA)); + await tester.pump(); + expect(nodeA.hasFocus, isTrue); + + nodeA.unfocus(); + await tester.pump(); + expect(nodeA.hasFocus, isFalse); + + final RequestFocusIntent focusIntentWithCallback = RequestFocusIntent(nodeA, requestFocusCallback: (FocusNode node, { + double? alignment, + ScrollPositionAlignmentPolicy? alignmentPolicy, + Curve? curve, + Duration? duration + }) => calledCallback = true); + + RequestFocusAction().invoke(focusIntentWithCallback); + await tester.pump(); + expect(calledCallback, isTrue); + }); } class TestRoute extends PageRouteBuilder {