diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index f9464c2708..799a2c107e 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -1446,6 +1446,17 @@ enum FocusHighlightStrategy { alwaysTraditional, } +// By extending the WidgetsBindingObserver class, +// we can add a listener object to FocusManager as a private member. +class _AppLifecycleListener extends WidgetsBindingObserver { + _AppLifecycleListener(this.onLifecycleStateChanged); + + final void Function(AppLifecycleState) onLifecycleStateChanged; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) => onLifecycleStateChanged(state); +} + /// Manages the focus tree. /// /// The focus tree is a separate, sparser, tree from the widget tree that @@ -1508,6 +1519,8 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier { if (kFlutterMemoryAllocationsEnabled) { ChangeNotifier.maybeDispatchObjectCreation(this); } + _appLifecycleListener = _AppLifecycleListener(_appLifecycleChange); + WidgetsBinding.instance.addObserver(_appLifecycleListener); rootScope._manager = this; } @@ -1524,6 +1537,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier { @override void dispose() { + WidgetsBinding.instance.removeObserver(_appLifecycleListener); _highlightManager.dispose(); rootScope.dispose(); super.dispose(); @@ -1682,6 +1696,34 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier { // update. final Set _dirtyNodes = {}; + // Allows FocusManager to respond to app lifecycle state changes, + // temporarily suspending the primaryFocus when the app is inactive. + late final _AppLifecycleListener _appLifecycleListener; + + // Stores the node that was focused before the app lifecycle changed. + // Will be restored as the primary focus once app is resumed. + FocusNode? _suspendedNode; + + void _appLifecycleChange(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (_primaryFocus != rootScope) { + assert(_focusDebug(() => 'focus changed while app was paused, ignoring $_suspendedNode')); + _suspendedNode = null; + } + else if (_suspendedNode != null) { + assert(_focusDebug(() => 'marking node $_suspendedNode to be focused')); + _markedForFocus = _suspendedNode; + _suspendedNode = null; + applyFocusChangesIfNeeded(); + } + } else if (_primaryFocus != rootScope) { + assert(_focusDebug(() => 'suspending $_primaryFocus')); + _markedForFocus = rootScope; + _suspendedNode = _primaryFocus; + applyFocusChangesIfNeeded(); + } + } + // The node that has requested to have the primary focus, but hasn't been // given it yet. FocusNode? _markedForFocus; @@ -1693,6 +1735,9 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier { if (_primaryFocus == node) { _primaryFocus = null; } + if (_suspendedNode == node) { + _suspendedNode = null; + } _dirtyNodes.remove(node); } diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart index 159966d91c..1b257ac862 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -354,6 +354,63 @@ void main() { logs.clear(); // ignore: deprecated_member_use }, variant: KeySimulatorTransitModeVariant.all()); + + testWidgets('FocusManager responds to app lifecycle changes.', (WidgetTester tester) async { + Future setAppLifecycleState(AppLifecycleState state) async { + final ByteData? message = const StringCodec().encodeMessage(state.toString()); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('flutter/lifecycle', message, (_) {}); + } + + final BuildContext context = await setupWidget(tester); + final FocusScopeNode scope = FocusScopeNode(debugLabel: 'Scope'); + addTearDown(scope.dispose); + final FocusAttachment scopeAttachment = scope.attach(context); + final FocusNode focusNode = FocusNode(debugLabel: 'Focus Node'); + addTearDown(focusNode.dispose); + final FocusAttachment focusNodeAttachment = focusNode.attach(context); + scopeAttachment.reparent(parent: tester.binding.focusManager.rootScope); + focusNodeAttachment.reparent(parent: scope); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await setAppLifecycleState(AppLifecycleState.paused); + expect(focusNode.hasPrimaryFocus, isFalse); + + await setAppLifecycleState(AppLifecycleState.resumed); + expect(focusNode.hasPrimaryFocus, isTrue); + }); + + testWidgets('Node is removed completely even if app is paused.', (WidgetTester tester) async { + Future setAppLifecycleState(AppLifecycleState state) async { + final ByteData? message = const StringCodec().encodeMessage(state.toString()); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('flutter/lifecycle', message, (_) {}); + } + + final BuildContext context = await setupWidget(tester); + final FocusScopeNode scope = FocusScopeNode(debugLabel: 'Scope'); + addTearDown(scope.dispose); + final FocusAttachment scopeAttachment = scope.attach(context); + final FocusNode focusNode = FocusNode(debugLabel: 'Focus Node'); + addTearDown(focusNode.dispose); + final FocusAttachment focusNodeAttachment = focusNode.attach(context); + scopeAttachment.reparent(parent: tester.binding.focusManager.rootScope); + focusNodeAttachment.reparent(parent: scope); + focusNode.requestFocus(); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await setAppLifecycleState(AppLifecycleState.paused); + expect(focusNode.hasPrimaryFocus, isFalse); + + focusNodeAttachment.detach(); + expect(focusNode.hasPrimaryFocus, isFalse); + + await setAppLifecycleState(AppLifecycleState.resumed); + expect(focusNode.hasPrimaryFocus, isFalse); + }); }); group(FocusScopeNode, () {