Expose callback that allows focus traversal customization (#120235)
This PR exposes a requestFocusCallback on `FocusTraversalPolicy` and it's inheritors. Fixes #83175.
This commit is contained in:
parent
1a76859cd6
commit
561169ec67
@ -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<FocusNode> sortDescendants(Iterable<FocusNode> 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<FocusTraversalGroup> {
|
||||
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<RequestFocusIntent> {
|
||||
|
||||
@override
|
||||
void invoke(RequestFocusIntent intent) {
|
||||
_focusAndEnsureVisible(intent.focusNode);
|
||||
intent.requestFocusCallback(intent.focusNode);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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(<int>[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<void> {
|
||||
|
Loading…
x
Reference in New Issue
Block a user