SelectionArea's selection should not be cleared on loss of window focus (#148067)

This change fixes an issue where SelectionArea would clear its selection when the application window lost focus by first checking if the application is running. This is needed because `FocusManager` is aware of the application lifecycle as of https://github.com/flutter/flutter/pull/142930 , and triggers a focus lost if the application is not active.

Also fixes an issue where the `FocusManager` was not being reset on tests at the right time, causing it always to build with `TargetPlatform.android` as its context.
This commit is contained in:
Renzo Olivares 2024-05-20 16:45:08 -07:00 committed by GitHub
parent 722c8d62fd
commit 5890a2fc73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 96 additions and 3 deletions

View File

@ -1873,6 +1873,34 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
}());
}
/// Enables this [FocusManager] to listen to changes of the application
/// lifecycle if it does not already have an application lifecycle listener
/// active, and the current platform is detected as [kIsWeb] or non-Android.
///
/// Typically, the application lifecycle listener for this [FocusManager] is
/// setup at construction, but sometimes it is necessary to manually initialize
/// it when the [FocusManager] does not have the relevant platform context in
/// [defaultTargetPlatform] at the time of construction. This can happen in
/// a test environment where the [BuildOwner] which initializes its own
/// [FocusManager], may not have the accurate platform context during its
/// initialization. In this case it is necessary for the test framework to call
/// this method after it has set up the test variant for a given test, so the
/// [FocusManager] can accurately listen to application lifecycle changes, if
/// supported.
@visibleForTesting
void listenToApplicationLifecycleChangesIfSupported() {
if (_appLifecycleListener == null && (kIsWeb || defaultTargetPlatform != TargetPlatform.android)) {
// It appears that some Android keyboard implementations can cause
// app lifecycle state changes: adding this listener would cause the
// text field to unfocus as the user is trying to type.
//
// Until this is resolved, we won't be adding the listener to Android apps.
// https://github.com/flutter/flutter/pull/142930#issuecomment-1981750069
_appLifecycleListener = _AppLifecycleListener(_appLifecycleChange);
WidgetsBinding.instance.addObserver(_appLifecycleListener!);
}
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
return <DiagnosticsNode>[

View File

@ -454,7 +454,16 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
if (kIsWeb) {
PlatformSelectableRegionContextMenu.detach(_selectionDelegate);
}
_clearSelection();
if (SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) {
// We should only clear the selection when this SelectableRegion loses
// focus while the application is currently running. It is possible
// that the application is not currently running, for example on desktop
// platforms, clicking on a different window switches the focus to
// the new window causing the Flutter application to go inactive. In this
// case we want to retain the selection so it remains when we return to
// the Flutter application.
_clearSelection();
}
}
if (kIsWeb) {
PlatformSelectableRegionContextMenu.attach(_selectionDelegate);

View File

@ -416,7 +416,7 @@ void main() {
await setAppLifecycleState(AppLifecycleState.resumed);
expect(focusNode.hasPrimaryFocus, isTrue);
});
}, variant: TargetPlatformVariant.desktop());
testWidgets('Node is removed completely even if app is paused.', (WidgetTester tester) async {
Future<void> setAppLifecycleState(AppLifecycleState state) async {

View File

@ -548,6 +548,54 @@ void main() {
}, variant: TargetPlatformVariant.all());
group('SelectionArea integration', () {
testWidgets('selection is not cleared when app loses focus on desktop', (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 FocusNode focusNode = FocusNode();
final GlobalKey selectableKey = GlobalKey();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
key: selectableKey,
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text('How are you'),
),
),
),
);
await setAppLifecycleState(AppLifecycleState.resumed);
await tester.pumpAndSettle();
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
await gesture.up();
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
expect(focusNode.hasFocus, isTrue);
// Setting the app lifecycle state to AppLifecycleState.inactive to simulate
// a lose of window focus.
await setAppLifecycleState(AppLifecycleState.inactive);
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isFalse);
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
}, variant: TargetPlatformVariant.desktop());
testWidgets('mouse can select single text on desktop platforms', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);

View File

@ -254,6 +254,14 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
_testTextInput.register();
}
CustomSemanticsAction.resetForTests(); // ignore: invalid_use_of_visible_for_testing_member
_enableFocusManagerLifecycleAwarenessIfSupported();
}
void _enableFocusManagerLifecycleAwarenessIfSupported() {
if (buildOwner == null) {
return;
}
buildOwner!.focusManager.listenToApplicationLifecycleChangesIfSupported(); // ignore: invalid_use_of_visible_for_testing_member
}
@override

View File

@ -174,11 +174,11 @@ void testWidgets(
test_package.addTearDown(binding.postTest);
return binding.runTest(
() async {
binding.reset(); // TODO(ianh): the binding should just do this itself in _runTest
debugResetSemanticsIdCounter();
Object? memento;
try {
memento = await variant.setUp(value);
binding.reset(); // TODO(ianh): the binding should just do this itself in _runTest
maybeSetupLeakTrackingForTest(experimentalLeakTesting, combinedDescription);
await callback(tester);
} finally {