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>  </details> <details open> <summary>This PR adds the same functionality to Flutter apps:</summary>  </details>
This commit is contained in:
parent
56387c0111
commit
f01ce9f4cb
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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, () {
|
||||
|
Loading…
x
Reference in New Issue
Block a user