Skip to content

Commit 0f16a0e

Browse files
authored
Adds a call to the PlatformDispatcher whenever the focus changes (flutter#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 flutter#151251 ## Tests - Added a test and some methods to the `TestPlatformDispatcher` to allow introspection of the values sent.
1 parent 49f9c9b commit 0f16a0e

File tree

5 files changed

+205
-6
lines changed

5 files changed

+205
-6
lines changed

packages/flutter/lib/src/widgets/focus_manager.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,7 +1536,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
15361536
if (kFlutterMemoryAllocationsEnabled) {
15371537
ChangeNotifier.maybeDispatchObjectCreation(this);
15381538
}
1539-
if (_respondToWindowFocus) {
1539+
if (_respondToLifecycleChange) {
15401540
_appLifecycleListener = _AppLifecycleListener(_appLifecycleChange);
15411541
WidgetsBinding.instance.addObserver(_appLifecycleListener!);
15421542
}
@@ -1553,7 +1553,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
15531553
/// Until these are resolved, we won't be adding the listener to mobile platforms.
15541554
/// https://github.com/flutter/flutter/issues/148475#issuecomment-2118407411
15551555
/// https://github.com/flutter/flutter/pull/142930#issuecomment-1981750069
1556-
bool get _respondToWindowFocus => kIsWeb || switch (defaultTargetPlatform) {
1556+
bool get _respondToLifecycleChange => kIsWeb || switch (defaultTargetPlatform) {
15571557
TargetPlatform.android || TargetPlatform.iOS => false,
15581558
TargetPlatform.fuchsia || TargetPlatform.linux => true,
15591559
TargetPlatform.windows || TargetPlatform.macOS => true,
@@ -1903,7 +1903,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
19031903
/// supported.
19041904
@visibleForTesting
19051905
void listenToApplicationLifecycleChangesIfSupported() {
1906-
if (_appLifecycleListener == null && _respondToWindowFocus) {
1906+
if (_appLifecycleListener == null && _respondToLifecycleChange) {
19071907
_appLifecycleListener = _AppLifecycleListener(_appLifecycleChange);
19081908
WidgetsBinding.instance.addObserver(_appLifecycleListener!);
19091909
}

packages/flutter/lib/src/widgets/view.dart

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,24 +193,43 @@ class _ViewState extends State<View> with WidgetsBindingObserver {
193193
debugLabel: kReleaseMode ? null : 'View Scope',
194194
);
195195
final FocusTraversalPolicy _policy = ReadingOrderTraversalPolicy();
196+
bool _viewHasFocus = false;
196197

197198
@override
198199
void initState() {
199200
super.initState();
200201
WidgetsBinding.instance.addObserver(this);
202+
_scopeNode.addListener(_scopeFocusChangeListener);
201203
}
202204

203205
@override
204206
void dispose() {
205207
WidgetsBinding.instance.removeObserver(this);
208+
_scopeNode.removeListener(_scopeFocusChangeListener);
206209
_scopeNode.dispose();
207210
super.dispose();
208211
}
209212

213+
void _scopeFocusChangeListener() {
214+
if (_viewHasFocus == _scopeNode.hasFocus || !_scopeNode.hasFocus) {
215+
return;
216+
}
217+
// Scope has gained focus, and it doesn't match the view focus, so inform
218+
// the view so it knows to change its focus.
219+
WidgetsBinding.instance.platformDispatcher.requestViewFocusChange(
220+
direction: ViewFocusDirection.forward,
221+
state: ViewFocusState.focused,
222+
viewId: widget.view.viewId,
223+
);
224+
}
225+
210226
@override
211227
void didChangeViewFocus(ViewFocusEvent event) {
228+
_viewHasFocus = switch (event.state) {
229+
ViewFocusState.focused => event.viewId == widget.view.viewId,
230+
ViewFocusState.unfocused => false,
231+
};
212232
if (event.viewId != widget.view.viewId) {
213-
// The event is not pertinent to this view.
214233
return;
215234
}
216235
FocusNode nextFocus;

packages/flutter/test/widgets/focus_manager_test.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,6 +2190,7 @@ void main() {
21902190
notifyCount++;
21912191
}
21922192
tester.binding.focusManager.addListener(handleFocusChange);
2193+
addTearDown(() => tester.binding.focusManager.removeListener(handleFocusChange));
21932194

21942195
nodeA.requestFocus();
21952196
await tester.pump();
@@ -2213,8 +2214,6 @@ void main() {
22132214
expect(nodeB.hasPrimaryFocus, isFalse);
22142215
expect(notifyCount, equals(1));
22152216
notifyCount = 0;
2216-
2217-
tester.binding.focusManager.removeListener(handleFocusChange);
22182217
});
22192218

22202219
testWidgets('debugFocusChanges causes logging of focus changes', (WidgetTester tester) async {

packages/flutter/test/widgets/view_test.dart

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,132 @@ void main() {
561561
expect(focusNode.hasPrimaryFocus, isTrue);
562562
expect(FocusManager.instance.rootScope.hasPrimaryFocus, isFalse);
563563
});
564+
565+
testWidgets('View notifies engine that a view should have focus when a widget focus change occurs.', (WidgetTester tester) async {
566+
final FocusNode nodeA = FocusNode(debugLabel: 'a');
567+
addTearDown(nodeA.dispose);
568+
569+
FlutterView? view;
570+
await tester.pumpWidget(
571+
Directionality(
572+
textDirection: TextDirection.rtl,
573+
child: Column(
574+
children: <Widget>[
575+
Focus(focusNode: nodeA, child: const Text('a')),
576+
Builder(builder: (BuildContext context) {
577+
view = View.of(context);
578+
return const SizedBox.shrink();
579+
}),
580+
],
581+
),
582+
),
583+
);
584+
int notifyCount = 0;
585+
void handleFocusChange() {
586+
notifyCount++;
587+
}
588+
tester.binding.focusManager.addListener(handleFocusChange);
589+
addTearDown(() => tester.binding.focusManager.removeListener(handleFocusChange));
590+
tester.binding.platformDispatcher.resetFocusedViewTestValues();
591+
592+
nodeA.requestFocus();
593+
await tester.pump();
594+
final List<ViewFocusEvent> events = tester.binding.platformDispatcher.testFocusEvents;
595+
expect(events.length, equals(1));
596+
expect(events.last.viewId, equals(view?.viewId));
597+
expect(events.last.direction, equals(ViewFocusDirection.forward));
598+
expect(events.last.state, equals(ViewFocusState.focused));
599+
expect(nodeA.hasPrimaryFocus, isTrue);
600+
expect(notifyCount, equals(1));
601+
notifyCount = 0;
602+
});
603+
604+
testWidgets('Switching focus between views yields the correct events.', (WidgetTester tester) async {
605+
final FocusNode nodeA = FocusNode(debugLabel: 'a');
606+
addTearDown(nodeA.dispose);
607+
608+
FlutterView? view;
609+
await tester.pumpWidget(
610+
Directionality(
611+
textDirection: TextDirection.rtl,
612+
child: Column(
613+
children: <Widget>[
614+
Focus(focusNode: nodeA, child: const Text('a')),
615+
Builder(builder: (BuildContext context) {
616+
view = View.of(context);
617+
return const SizedBox.shrink();
618+
}),
619+
],
620+
),
621+
),
622+
);
623+
int notifyCount = 0;
624+
void handleFocusChange() {
625+
notifyCount++;
626+
}
627+
tester.binding.focusManager.addListener(handleFocusChange);
628+
addTearDown(() => tester.binding.focusManager.removeListener(handleFocusChange));
629+
tester.binding.platformDispatcher.resetFocusedViewTestValues();
630+
631+
// Focus and make sure engine is notified.
632+
nodeA.requestFocus();
633+
await tester.pump();
634+
List<ViewFocusEvent> events = tester.binding.platformDispatcher.testFocusEvents;
635+
expect(events.length, equals(1));
636+
expect(events.last.viewId, equals(view?.viewId));
637+
expect(events.last.direction, equals(ViewFocusDirection.forward));
638+
expect(events.last.state, equals(ViewFocusState.focused));
639+
expect(nodeA.hasPrimaryFocus, isTrue);
640+
expect(notifyCount, equals(1));
641+
notifyCount = 0;
642+
tester.binding.platformDispatcher.resetFocusedViewTestValues();
643+
644+
// Unfocus all views.
645+
tester.binding.platformDispatcher.onViewFocusChange?.call(
646+
ViewFocusEvent(
647+
viewId: view!.viewId,
648+
state: ViewFocusState.unfocused,
649+
direction: ViewFocusDirection.forward,
650+
),
651+
);
652+
await tester.pump();
653+
expect(nodeA.hasFocus, isFalse);
654+
expect(tester.binding.platformDispatcher.testFocusEvents, isEmpty);
655+
expect(notifyCount, equals(1));
656+
notifyCount = 0;
657+
tester.binding.platformDispatcher.resetFocusedViewTestValues();
658+
659+
// Focus another view.
660+
tester.binding.platformDispatcher.onViewFocusChange?.call(
661+
const ViewFocusEvent(
662+
viewId: 100,
663+
state: ViewFocusState.focused,
664+
direction: ViewFocusDirection.forward,
665+
),
666+
);
667+
668+
// Focusing another view should unfocus this node without notifying the
669+
// engine to unfocus.
670+
await tester.pump();
671+
expect(nodeA.hasFocus, isFalse);
672+
expect(tester.binding.platformDispatcher.testFocusEvents, isEmpty);
673+
expect(notifyCount, equals(0));
674+
notifyCount = 0;
675+
tester.binding.platformDispatcher.resetFocusedViewTestValues();
676+
677+
// Re-focusing the node should notify the engine that this view is focused.
678+
nodeA.requestFocus();
679+
await tester.pump();
680+
expect(nodeA.hasPrimaryFocus, isTrue);
681+
events = tester.binding.platformDispatcher.testFocusEvents;
682+
expect(events.length, equals(1));
683+
expect(events.last.viewId, equals(view?.viewId));
684+
expect(events.last.direction, equals(ViewFocusDirection.forward));
685+
expect(events.last.state, equals(ViewFocusState.focused));
686+
expect(notifyCount, equals(1));
687+
notifyCount = 0;
688+
tester.binding.platformDispatcher.resetFocusedViewTestValues();
689+
});
564690
}
565691

566692
class SpyRenderWidget extends SizedBox {

packages/flutter_test/lib/src/window.dart

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ library;
1111
import 'dart:ui' hide window;
1212

1313
import 'package:flutter/foundation.dart';
14+
import 'package:flutter/widgets.dart';
1415

1516
/// Test version of [AccessibilityFeatures] in which specific features may
1617
/// be set to arbitrary values.
@@ -197,9 +198,63 @@ class TestPlatformDispatcher implements PlatformDispatcher {
197198
}
198199
void _handleViewFocusChanged(ViewFocusEvent event) {
199200
_updateViewsAndDisplays();
201+
_currentlyFocusedViewId = switch (event.state) {
202+
ViewFocusState.focused => event.viewId,
203+
ViewFocusState.unfocused => null,
204+
};
200205
_onViewFocusChange?.call(event);
201206
}
202207

208+
/// Returns the list of [ViewFocusEvent]s that have been received by
209+
/// [requestViewFocusChange] since the last call to
210+
/// [resetFocusedViewTestValues].
211+
///
212+
/// Clearing or modifying the returned list will do nothing (it's a copy).
213+
/// Call [resetFocusedViewTestValues] to clear.
214+
List<ViewFocusEvent> get testFocusEvents => _testFocusEvents.toList();
215+
final List<ViewFocusEvent> _testFocusEvents = <ViewFocusEvent>[];
216+
217+
/// Returns the last view ID to be focused by [onViewFocusChange].
218+
/// Returns null if no views are focused.
219+
///
220+
/// Can be reset to null with [resetFocusedViewTestValues].
221+
int? get currentlyFocusedViewIdTestValue => _currentlyFocusedViewId;
222+
int? _currentlyFocusedViewId;
223+
224+
/// Clears [testFocusEvents] and sets [currentlyFocusedViewIdTestValue] to
225+
/// null.
226+
void resetFocusedViewTestValues() {
227+
if (_currentlyFocusedViewId != null) {
228+
// If there is a focused view, then tell everyone who still cares that
229+
// it's unfocusing.
230+
_platformDispatcher.onViewFocusChange?.call(
231+
ViewFocusEvent(
232+
viewId: _currentlyFocusedViewId!,
233+
state: ViewFocusState.unfocused,
234+
direction: ViewFocusDirection.undefined,
235+
),
236+
);
237+
_currentlyFocusedViewId = null;
238+
}
239+
_testFocusEvents.clear();
240+
}
241+
242+
@override
243+
void requestViewFocusChange({
244+
required int viewId,
245+
required ViewFocusState state,
246+
required ViewFocusDirection direction,
247+
}) {
248+
_testFocusEvents.add(
249+
ViewFocusEvent(
250+
viewId: viewId,
251+
state: state,
252+
direction: direction,
253+
),
254+
);
255+
_platformDispatcher.requestViewFocusChange(viewId: viewId, state: state, direction: direction);
256+
}
257+
203258
@override
204259
Locale get locale => _localeTestValue ?? _platformDispatcher.locale;
205260
Locale? _localeTestValue;

0 commit comments

Comments
 (0)