Adds a call to the PlatformDispatcher whenever the focus changes (#151268)

## Description

This adds a call to the `PlatformDispatcher` whenever the focus changes, so that the engine can decide what to do about view focus.  This lets widgets use autofocus, and when they are focused their view will also receive focus.

## Related Issues
 - Fixes https://github.com/flutter/flutter/issues/151251

## Tests
 - Added a test and some methods to the `TestPlatformDispatcher` to allow introspection of the values sent.
This commit is contained in:
Greg Spencer 2024-07-25 17:27:37 -07:00 committed by GitHub
parent 49f9c9bf58
commit 0f16a0e5e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 205 additions and 6 deletions

View File

@ -1536,7 +1536,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
if (kFlutterMemoryAllocationsEnabled) {
ChangeNotifier.maybeDispatchObjectCreation(this);
}
if (_respondToWindowFocus) {
if (_respondToLifecycleChange) {
_appLifecycleListener = _AppLifecycleListener(_appLifecycleChange);
WidgetsBinding.instance.addObserver(_appLifecycleListener!);
}
@ -1553,7 +1553,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
/// Until these are resolved, we won't be adding the listener to mobile platforms.
/// https://github.com/flutter/flutter/issues/148475#issuecomment-2118407411
/// https://github.com/flutter/flutter/pull/142930#issuecomment-1981750069
bool get _respondToWindowFocus => kIsWeb || switch (defaultTargetPlatform) {
bool get _respondToLifecycleChange => kIsWeb || switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => false,
TargetPlatform.fuchsia || TargetPlatform.linux => true,
TargetPlatform.windows || TargetPlatform.macOS => true,
@ -1903,7 +1903,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
/// supported.
@visibleForTesting
void listenToApplicationLifecycleChangesIfSupported() {
if (_appLifecycleListener == null && _respondToWindowFocus) {
if (_appLifecycleListener == null && _respondToLifecycleChange) {
_appLifecycleListener = _AppLifecycleListener(_appLifecycleChange);
WidgetsBinding.instance.addObserver(_appLifecycleListener!);
}

View File

@ -193,24 +193,43 @@ class _ViewState extends State<View> with WidgetsBindingObserver {
debugLabel: kReleaseMode ? null : 'View Scope',
);
final FocusTraversalPolicy _policy = ReadingOrderTraversalPolicy();
bool _viewHasFocus = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scopeNode.addListener(_scopeFocusChangeListener);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scopeNode.removeListener(_scopeFocusChangeListener);
_scopeNode.dispose();
super.dispose();
}
void _scopeFocusChangeListener() {
if (_viewHasFocus == _scopeNode.hasFocus || !_scopeNode.hasFocus) {
return;
}
// Scope has gained focus, and it doesn't match the view focus, so inform
// the view so it knows to change its focus.
WidgetsBinding.instance.platformDispatcher.requestViewFocusChange(
direction: ViewFocusDirection.forward,
state: ViewFocusState.focused,
viewId: widget.view.viewId,
);
}
@override
void didChangeViewFocus(ViewFocusEvent event) {
_viewHasFocus = switch (event.state) {
ViewFocusState.focused => event.viewId == widget.view.viewId,
ViewFocusState.unfocused => false,
};
if (event.viewId != widget.view.viewId) {
// The event is not pertinent to this view.
return;
}
FocusNode nextFocus;

View File

@ -2190,6 +2190,7 @@ void main() {
notifyCount++;
}
tester.binding.focusManager.addListener(handleFocusChange);
addTearDown(() => tester.binding.focusManager.removeListener(handleFocusChange));
nodeA.requestFocus();
await tester.pump();
@ -2213,8 +2214,6 @@ void main() {
expect(nodeB.hasPrimaryFocus, isFalse);
expect(notifyCount, equals(1));
notifyCount = 0;
tester.binding.focusManager.removeListener(handleFocusChange);
});
testWidgets('debugFocusChanges causes logging of focus changes', (WidgetTester tester) async {

View File

@ -561,6 +561,132 @@ void main() {
expect(focusNode.hasPrimaryFocus, isTrue);
expect(FocusManager.instance.rootScope.hasPrimaryFocus, isFalse);
});
testWidgets('View notifies engine that a view should have focus when a widget focus change occurs.', (WidgetTester tester) async {
final FocusNode nodeA = FocusNode(debugLabel: 'a');
addTearDown(nodeA.dispose);
FlutterView? view;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.rtl,
child: Column(
children: <Widget>[
Focus(focusNode: nodeA, child: const Text('a')),
Builder(builder: (BuildContext context) {
view = View.of(context);
return const SizedBox.shrink();
}),
],
),
),
);
int notifyCount = 0;
void handleFocusChange() {
notifyCount++;
}
tester.binding.focusManager.addListener(handleFocusChange);
addTearDown(() => tester.binding.focusManager.removeListener(handleFocusChange));
tester.binding.platformDispatcher.resetFocusedViewTestValues();
nodeA.requestFocus();
await tester.pump();
final List<ViewFocusEvent> events = tester.binding.platformDispatcher.testFocusEvents;
expect(events.length, equals(1));
expect(events.last.viewId, equals(view?.viewId));
expect(events.last.direction, equals(ViewFocusDirection.forward));
expect(events.last.state, equals(ViewFocusState.focused));
expect(nodeA.hasPrimaryFocus, isTrue);
expect(notifyCount, equals(1));
notifyCount = 0;
});
testWidgets('Switching focus between views yields the correct events.', (WidgetTester tester) async {
final FocusNode nodeA = FocusNode(debugLabel: 'a');
addTearDown(nodeA.dispose);
FlutterView? view;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.rtl,
child: Column(
children: <Widget>[
Focus(focusNode: nodeA, child: const Text('a')),
Builder(builder: (BuildContext context) {
view = View.of(context);
return const SizedBox.shrink();
}),
],
),
),
);
int notifyCount = 0;
void handleFocusChange() {
notifyCount++;
}
tester.binding.focusManager.addListener(handleFocusChange);
addTearDown(() => tester.binding.focusManager.removeListener(handleFocusChange));
tester.binding.platformDispatcher.resetFocusedViewTestValues();
// Focus and make sure engine is notified.
nodeA.requestFocus();
await tester.pump();
List<ViewFocusEvent> events = tester.binding.platformDispatcher.testFocusEvents;
expect(events.length, equals(1));
expect(events.last.viewId, equals(view?.viewId));
expect(events.last.direction, equals(ViewFocusDirection.forward));
expect(events.last.state, equals(ViewFocusState.focused));
expect(nodeA.hasPrimaryFocus, isTrue);
expect(notifyCount, equals(1));
notifyCount = 0;
tester.binding.platformDispatcher.resetFocusedViewTestValues();
// Unfocus all views.
tester.binding.platformDispatcher.onViewFocusChange?.call(
ViewFocusEvent(
viewId: view!.viewId,
state: ViewFocusState.unfocused,
direction: ViewFocusDirection.forward,
),
);
await tester.pump();
expect(nodeA.hasFocus, isFalse);
expect(tester.binding.platformDispatcher.testFocusEvents, isEmpty);
expect(notifyCount, equals(1));
notifyCount = 0;
tester.binding.platformDispatcher.resetFocusedViewTestValues();
// Focus another view.
tester.binding.platformDispatcher.onViewFocusChange?.call(
const ViewFocusEvent(
viewId: 100,
state: ViewFocusState.focused,
direction: ViewFocusDirection.forward,
),
);
// Focusing another view should unfocus this node without notifying the
// engine to unfocus.
await tester.pump();
expect(nodeA.hasFocus, isFalse);
expect(tester.binding.platformDispatcher.testFocusEvents, isEmpty);
expect(notifyCount, equals(0));
notifyCount = 0;
tester.binding.platformDispatcher.resetFocusedViewTestValues();
// Re-focusing the node should notify the engine that this view is focused.
nodeA.requestFocus();
await tester.pump();
expect(nodeA.hasPrimaryFocus, isTrue);
events = tester.binding.platformDispatcher.testFocusEvents;
expect(events.length, equals(1));
expect(events.last.viewId, equals(view?.viewId));
expect(events.last.direction, equals(ViewFocusDirection.forward));
expect(events.last.state, equals(ViewFocusState.focused));
expect(notifyCount, equals(1));
notifyCount = 0;
tester.binding.platformDispatcher.resetFocusedViewTestValues();
});
}
class SpyRenderWidget extends SizedBox {

View File

@ -11,6 +11,7 @@ library;
import 'dart:ui' hide window;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// Test version of [AccessibilityFeatures] in which specific features may
/// be set to arbitrary values.
@ -197,9 +198,63 @@ class TestPlatformDispatcher implements PlatformDispatcher {
}
void _handleViewFocusChanged(ViewFocusEvent event) {
_updateViewsAndDisplays();
_currentlyFocusedViewId = switch (event.state) {
ViewFocusState.focused => event.viewId,
ViewFocusState.unfocused => null,
};
_onViewFocusChange?.call(event);
}
/// Returns the list of [ViewFocusEvent]s that have been received by
/// [requestViewFocusChange] since the last call to
/// [resetFocusedViewTestValues].
///
/// Clearing or modifying the returned list will do nothing (it's a copy).
/// Call [resetFocusedViewTestValues] to clear.
List<ViewFocusEvent> get testFocusEvents => _testFocusEvents.toList();
final List<ViewFocusEvent> _testFocusEvents = <ViewFocusEvent>[];
/// Returns the last view ID to be focused by [onViewFocusChange].
/// Returns null if no views are focused.
///
/// Can be reset to null with [resetFocusedViewTestValues].
int? get currentlyFocusedViewIdTestValue => _currentlyFocusedViewId;
int? _currentlyFocusedViewId;
/// Clears [testFocusEvents] and sets [currentlyFocusedViewIdTestValue] to
/// null.
void resetFocusedViewTestValues() {
if (_currentlyFocusedViewId != null) {
// If there is a focused view, then tell everyone who still cares that
// it's unfocusing.
_platformDispatcher.onViewFocusChange?.call(
ViewFocusEvent(
viewId: _currentlyFocusedViewId!,
state: ViewFocusState.unfocused,
direction: ViewFocusDirection.undefined,
),
);
_currentlyFocusedViewId = null;
}
_testFocusEvents.clear();
}
@override
void requestViewFocusChange({
required int viewId,
required ViewFocusState state,
required ViewFocusDirection direction,
}) {
_testFocusEvents.add(
ViewFocusEvent(
viewId: viewId,
state: state,
direction: direction,
),
);
_platformDispatcher.requestViewFocusChange(viewId: viewId, state: state, direction: direction);
}
@override
Locale get locale => _localeTestValue ?? _platformDispatcher.locale;
Locale? _localeTestValue;