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,
|
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.
|
/// Manages the focus tree.
|
||||||
///
|
///
|
||||||
/// The focus tree is a separate, sparser, tree from the widget tree that
|
/// The focus tree is a separate, sparser, tree from the widget tree that
|
||||||
@ -1508,6 +1519,8 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
|
|||||||
if (kFlutterMemoryAllocationsEnabled) {
|
if (kFlutterMemoryAllocationsEnabled) {
|
||||||
ChangeNotifier.maybeDispatchObjectCreation(this);
|
ChangeNotifier.maybeDispatchObjectCreation(this);
|
||||||
}
|
}
|
||||||
|
_appLifecycleListener = _AppLifecycleListener(_appLifecycleChange);
|
||||||
|
WidgetsBinding.instance.addObserver(_appLifecycleListener);
|
||||||
rootScope._manager = this;
|
rootScope._manager = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1524,6 +1537,7 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(_appLifecycleListener);
|
||||||
_highlightManager.dispose();
|
_highlightManager.dispose();
|
||||||
rootScope.dispose();
|
rootScope.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@ -1682,6 +1696,34 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
|
|||||||
// update.
|
// update.
|
||||||
final Set<FocusNode> _dirtyNodes = <FocusNode>{};
|
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
|
// The node that has requested to have the primary focus, but hasn't been
|
||||||
// given it yet.
|
// given it yet.
|
||||||
FocusNode? _markedForFocus;
|
FocusNode? _markedForFocus;
|
||||||
@ -1693,6 +1735,9 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
|
|||||||
if (_primaryFocus == node) {
|
if (_primaryFocus == node) {
|
||||||
_primaryFocus = null;
|
_primaryFocus = null;
|
||||||
}
|
}
|
||||||
|
if (_suspendedNode == node) {
|
||||||
|
_suspendedNode = null;
|
||||||
|
}
|
||||||
_dirtyNodes.remove(node);
|
_dirtyNodes.remove(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -354,6 +354,63 @@ void main() {
|
|||||||
logs.clear();
|
logs.clear();
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
}, variant: KeySimulatorTransitModeVariant.all());
|
}, 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, () {
|
group(FocusScopeNode, () {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user