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:
parent
49f9c9bf58
commit
0f16a0e5e3
@ -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!);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user