Skip to content

Commit

Permalink
SelectionArea's selection should not be cleared on loss of window foc…
Browse files Browse the repository at this point in the history
…us (#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.
  • Loading branch information
Renzo-Olivares authored May 20, 2024
1 parent 722c8d6 commit 5890a2f
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 3 deletions.
28 changes: 28 additions & 0 deletions packages/flutter/lib/src/widgets/focus_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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>[
Expand Down
11 changes: 10 additions & 1 deletion packages/flutter/lib/src/widgets/selectable_region.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/flutter/test/widgets/focus_manager_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions packages/flutter/test/widgets/selectable_region_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions packages/flutter_test/lib/src/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/flutter_test/lib/src/widget_tester.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 5890a2f

Please sign in to comment.