Skip to content

Commit 5890a2f

Browse files
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 flutter/flutter#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.
1 parent 722c8d6 commit 5890a2f

File tree

6 files changed

+96
-3
lines changed

6 files changed

+96
-3
lines changed

packages/flutter/lib/src/widgets/focus_manager.dart

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1873,6 +1873,34 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
18731873
}());
18741874
}
18751875

1876+
/// Enables this [FocusManager] to listen to changes of the application
1877+
/// lifecycle if it does not already have an application lifecycle listener
1878+
/// active, and the current platform is detected as [kIsWeb] or non-Android.
1879+
///
1880+
/// Typically, the application lifecycle listener for this [FocusManager] is
1881+
/// setup at construction, but sometimes it is necessary to manually initialize
1882+
/// it when the [FocusManager] does not have the relevant platform context in
1883+
/// [defaultTargetPlatform] at the time of construction. This can happen in
1884+
/// a test environment where the [BuildOwner] which initializes its own
1885+
/// [FocusManager], may not have the accurate platform context during its
1886+
/// initialization. In this case it is necessary for the test framework to call
1887+
/// this method after it has set up the test variant for a given test, so the
1888+
/// [FocusManager] can accurately listen to application lifecycle changes, if
1889+
/// supported.
1890+
@visibleForTesting
1891+
void listenToApplicationLifecycleChangesIfSupported() {
1892+
if (_appLifecycleListener == null && (kIsWeb || defaultTargetPlatform != TargetPlatform.android)) {
1893+
// It appears that some Android keyboard implementations can cause
1894+
// app lifecycle state changes: adding this listener would cause the
1895+
// text field to unfocus as the user is trying to type.
1896+
//
1897+
// Until this is resolved, we won't be adding the listener to Android apps.
1898+
// https://github.com/flutter/flutter/pull/142930#issuecomment-1981750069
1899+
_appLifecycleListener = _AppLifecycleListener(_appLifecycleChange);
1900+
WidgetsBinding.instance.addObserver(_appLifecycleListener!);
1901+
}
1902+
}
1903+
18761904
@override
18771905
List<DiagnosticsNode> debugDescribeChildren() {
18781906
return <DiagnosticsNode>[

packages/flutter/lib/src/widgets/selectable_region.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,16 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
454454
if (kIsWeb) {
455455
PlatformSelectableRegionContextMenu.detach(_selectionDelegate);
456456
}
457-
_clearSelection();
457+
if (SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) {
458+
// We should only clear the selection when this SelectableRegion loses
459+
// focus while the application is currently running. It is possible
460+
// that the application is not currently running, for example on desktop
461+
// platforms, clicking on a different window switches the focus to
462+
// the new window causing the Flutter application to go inactive. In this
463+
// case we want to retain the selection so it remains when we return to
464+
// the Flutter application.
465+
_clearSelection();
466+
}
458467
}
459468
if (kIsWeb) {
460469
PlatformSelectableRegionContextMenu.attach(_selectionDelegate);

packages/flutter/test/widgets/focus_manager_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ void main() {
416416

417417
await setAppLifecycleState(AppLifecycleState.resumed);
418418
expect(focusNode.hasPrimaryFocus, isTrue);
419-
});
419+
}, variant: TargetPlatformVariant.desktop());
420420

421421
testWidgets('Node is removed completely even if app is paused.', (WidgetTester tester) async {
422422
Future<void> setAppLifecycleState(AppLifecycleState state) async {

packages/flutter/test/widgets/selectable_region_test.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,54 @@ void main() {
548548
}, variant: TargetPlatformVariant.all());
549549

550550
group('SelectionArea integration', () {
551+
testWidgets('selection is not cleared when app loses focus on desktop', (WidgetTester tester) async {
552+
Future<void> setAppLifecycleState(AppLifecycleState state) async {
553+
final ByteData? message = const StringCodec().encodeMessage(state.toString());
554+
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
555+
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
556+
}
557+
final FocusNode focusNode = FocusNode();
558+
final GlobalKey selectableKey = GlobalKey();
559+
addTearDown(focusNode.dispose);
560+
await tester.pumpWidget(
561+
MaterialApp(
562+
home: SelectableRegion(
563+
key: selectableKey,
564+
focusNode: focusNode,
565+
selectionControls: materialTextSelectionControls,
566+
child: const Center(
567+
child: Text('How are you'),
568+
),
569+
),
570+
),
571+
);
572+
await setAppLifecycleState(AppLifecycleState.resumed);
573+
await tester.pumpAndSettle();
574+
575+
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you'), matching: find.byType(RichText)));
576+
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse);
577+
addTearDown(gesture.removePointer);
578+
await tester.pump();
579+
await gesture.up();
580+
await tester.pump();
581+
582+
await gesture.down(textOffsetToPosition(paragraph, 2));
583+
await tester.pumpAndSettle();
584+
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
585+
586+
await gesture.up();
587+
await tester.pumpAndSettle();
588+
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
589+
expect(focusNode.hasFocus, isTrue);
590+
591+
// Setting the app lifecycle state to AppLifecycleState.inactive to simulate
592+
// a lose of window focus.
593+
await setAppLifecycleState(AppLifecycleState.inactive);
594+
await tester.pumpAndSettle();
595+
expect(focusNode.hasFocus, isFalse);
596+
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
597+
}, variant: TargetPlatformVariant.desktop());
598+
551599
testWidgets('mouse can select single text on desktop platforms', (WidgetTester tester) async {
552600
final FocusNode focusNode = FocusNode();
553601
addTearDown(focusNode.dispose);

packages/flutter_test/lib/src/binding.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,14 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
254254
_testTextInput.register();
255255
}
256256
CustomSemanticsAction.resetForTests(); // ignore: invalid_use_of_visible_for_testing_member
257+
_enableFocusManagerLifecycleAwarenessIfSupported();
258+
}
259+
260+
void _enableFocusManagerLifecycleAwarenessIfSupported() {
261+
if (buildOwner == null) {
262+
return;
263+
}
264+
buildOwner!.focusManager.listenToApplicationLifecycleChangesIfSupported(); // ignore: invalid_use_of_visible_for_testing_member
257265
}
258266

259267
@override

packages/flutter_test/lib/src/widget_tester.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,11 @@ void testWidgets(
174174
test_package.addTearDown(binding.postTest);
175175
return binding.runTest(
176176
() async {
177-
binding.reset(); // TODO(ianh): the binding should just do this itself in _runTest
178177
debugResetSemanticsIdCounter();
179178
Object? memento;
180179
try {
181180
memento = await variant.setUp(value);
181+
binding.reset(); // TODO(ianh): the binding should just do this itself in _runTest
182182
maybeSetupLeakTrackingForTest(experimentalLeakTesting, combinedDescription);
183183
await callback(tester);
184184
} finally {

0 commit comments

Comments
 (0)