diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index ac0f15116d..a20b5a5d23 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -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!); } diff --git a/packages/flutter/lib/src/widgets/view.dart b/packages/flutter/lib/src/widgets/view.dart index aff599d938..90e06cb4c5 100644 --- a/packages/flutter/lib/src/widgets/view.dart +++ b/packages/flutter/lib/src/widgets/view.dart @@ -193,24 +193,43 @@ class _ViewState extends State 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; diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart index 76c4a80250..c0e14682f2 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -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 { diff --git a/packages/flutter/test/widgets/view_test.dart b/packages/flutter/test/widgets/view_test.dart index c272761e4a..f9bf426a95 100644 --- a/packages/flutter/test/widgets/view_test.dart +++ b/packages/flutter/test/widgets/view_test.dart @@ -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: [ + 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 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: [ + 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 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 { diff --git a/packages/flutter_test/lib/src/window.dart b/packages/flutter_test/lib/src/window.dart index ef35baf5f3..21c1a5c269 100644 --- a/packages/flutter_test/lib/src/window.dart +++ b/packages/flutter_test/lib/src/window.dart @@ -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 get testFocusEvents => _testFocusEvents.toList(); + final List _testFocusEvents = []; + + /// 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;