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.

<details open>
<summary>Example (Chrome):</summary>

![focus node](https://github.com/flutter/flutter/assets/10457200/bef42cd9-28e5-4214-b071-b7ef56b26609)

</details>

<details open>
<summary>This PR adds the same functionality to Flutter apps:</summary>

![Flutter demo](https://github.com/flutter/flutter/assets/10457200/6eb34c44-5fb0-4b27-aa10-6606a1eb187e)

</details>
This commit is contained in:
Nate 2024-02-13 16:27:19 -07:00 committed by GitHub
parent 56387c0111
commit f01ce9f4cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 102 additions and 0 deletions

View file

@ -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<FocusNode> _dirtyNodes = <FocusNode>{};
// 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);
}

View file

@ -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<void> 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<void> 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, () {