From f01ce9f4cb41beff7b85122b5fcf1228bb655a87 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 13 Feb 2024 16:27:19 -0700 Subject: [PATCH] Have `FocusManager` respond to app lifecycle state changes (#142930) fixes #87061 It doesn't matter whether I'm using Google Chrome, VS Code, Discord, or a Terminal window: any time a text cursor is blinking, it means that the characters I type will show up there. And this isn't limited to text fields: if I repeatedly press `Tab` to navigate through a website, there's a visual indicator that goes away if I click away from the window, and it comes back if I click or `Alt+Tab` back into it.
Example (Chrome): ![focus node](https://github.com/flutter/flutter/assets/10457200/bef42cd9-28e5-4214-b071-b7ef56b26609)
This PR adds the same functionality to Flutter apps: ![Flutter demo](https://github.com/flutter/flutter/assets/10457200/6eb34c44-5fb0-4b27-aa10-6606a1eb187e)
--- .../lib/src/widgets/focus_manager.dart | 45 +++++++++++++++ .../test/widgets/focus_manager_test.dart | 57 +++++++++++++++++++ 2 files changed, 102 insertions(+) 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, () {