fix: SelectableText should handle focus changes (#155771)

This change updates the behavior of `SelectableText`, to clear its selection when it loses focus and the application is currently running. This fixes the behavior where you may have multiple active highlights if you have `SelectableText` along with other "selectable" widgets such as `TextField`, or `Text` widgets under a `SelectionArea`.

If the application is in the background, for example when another window is focused, the selection should be retained so when a user returns to the application it is still there.

This change also updates the behavior of selection on macOS, single tap up, previously it was selecting the word edge closest to the tapped position, the correct behavior on native is to select the precise position. This was causing `onSelectionChanged` to be called twice, once for tap down (sets the precise tapped position, handled by logic in `TextSelectionGestureDetector`), and a second time for single tap up (moves the cursor to closest word edge, handled by logic in `_SelectableTextSelectionGestureDetectorBuilder`). This type of selection inconsistency is related to this issue https://github.com/flutter/flutter/issues/129726, I plan to look into this further in a separate PR.

Fixes #117573
Fixes #103725
This commit is contained in:
Renzo Olivares 2024-09-26 20:17:05 -04:00 committed by GitHub
parent 5b32f33271
commit 799cf16aeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 192 additions and 33 deletions

View File

@ -14,6 +14,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'desktop_text_selection.dart';
@ -135,18 +136,19 @@ class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestur
@override
void onSingleTapUp(TapDragUpDetails details) {
if (!delegate.selectionEnabled) {
return;
}
editableText.hideToolbar();
if (delegate.selectionEnabled) {
switch (Theme.of(_state.context).platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
}
switch (Theme.of(_state.context).platform) {
case TargetPlatform.iOS:
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
case TargetPlatform.macOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
}
_state.widget.onTap?.call();
}
@ -582,6 +584,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
textSpan: widget.textSpan ?? TextSpan(text: widget.data),
);
_controller.addListener(_onControllerChanged);
_effectiveFocusNode.addListener(_handleFocusChanged);
}
@override
@ -595,6 +598,10 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
);
_controller.addListener(_onControllerChanged);
}
if (widget.focusNode != oldWidget.focusNode) {
(oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged);
(widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged);
}
if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
_showSelectionHandles = false;
} else {
@ -604,6 +611,7 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
@override
void dispose() {
_effectiveFocusNode.removeListener(_handleFocusChanged);
_focusNode?.dispose();
_controller.dispose();
super.dispose();
@ -620,6 +628,20 @@ class _SelectableTextState extends State<SelectableText> implements TextSelectio
});
}
void _handleFocusChanged() {
if (!_effectiveFocusNode.hasFocus
&& SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) {
// We should only clear the selection when this SelectableText 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.
_controller.value = TextEditingValue(text: _controller.value.text);
}
}
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) {
final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
if (willShowSelectionHandles != _showSelectionHandles) {

View File

@ -1475,6 +1475,59 @@ void main() {
expect(topLeft.dx, equals(399.0));
});
testWidgets('Tapping outside SelectableText clears the selection', (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, (_) {});
}
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: Column(
children: <Widget>[
SelectableText('first selectable text'),
SelectableText('second selectable text'),
],
),
),
),
),
);
// Setting the app lifecycle state to AppLifecycleState.resumed to simulate
// an applications default running mode, i.e. the application window is focused.
await setAppLifecycleState(AppLifecycleState.resumed);
await tester.pumpAndSettle();
// First tap on the first SelectableText sets the cursor.
await tester.tapAt(textOffsetToPosition(tester, 5));
await tester.pumpAndSettle();
final EditableText editableTextWidgetFirst = tester.widget(find.byType(EditableText).first);
final TextEditingController controllerA = editableTextWidgetFirst.controller;
final EditableText editableTextWidgetSecond = tester.widget(find.byType(EditableText).last);
final TextEditingController controllerB = editableTextWidgetSecond.controller;
expect(controllerA.selection, const TextSelection.collapsed(offset: 5));
expect(controllerB.selection, TextRange.empty);
// Tapping on the second SelectableText sets the cursor on it, and clears the selection from
// the first SelectableText.
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText).last);
await tester.tapAt(selectableTextStart);
await tester.pumpAndSettle();
expect(controllerA.selection, TextRange.empty);
expect(controllerB.selection, const TextSelection.collapsed(offset: 0));
// Setting the app lifecycle state to AppLifecycleState.inactive to simulate
// a lose of window focus. Selection should remain the same.
await setAppLifecycleState(AppLifecycleState.inactive);
await tester.pumpAndSettle();
expect(controllerA.selection, TextRange.empty);
expect(controllerB.selection, const TextSelection.collapsed(offset: 0));
});
testWidgets('Selectable text is skipped during focus traversal', (WidgetTester tester) async {
final FocusNode firstFieldFocus = FocusNode();
addTearDown(firstFieldFocus.dispose);
@ -2900,7 +2953,7 @@ void main() {
// But don't trigger the toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets(
@ -2933,10 +2986,11 @@ void main() {
// But don't trigger the toolbar.
expect(find.byType(TextButton), findsNothing);
},
variant: TargetPlatformVariant.all(excluding: const <TargetPlatform>{ TargetPlatform.iOS })
);
testWidgets(
'two slow taps do not trigger a word selection',
'two slow taps do not trigger a word selection on iOS',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
@ -2967,7 +3021,42 @@ void main() {
// No toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets(
'two slow taps do not trigger a word selection',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
),
),
),
);
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump();
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
final TextEditingController controller = editableTextWidget.controller;
// Plain collapsed selection.
expect(
controller.selection,
const TextSelection.collapsed(offset: 4, affinity: TextAffinity.upstream),
);
// No toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets(
@ -2997,9 +3086,11 @@ void main() {
final TextEditingController controller = editableTextWidget.controller;
// First tap moved the cursor.
// On iOS, this moves the cursor to the closest word edge.
// On macOS, this moves the cursor to the tapped position.
expect(
controller.selection,
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
TextSelection.collapsed(offset: defaultTargetPlatform == TargetPlatform.iOS ? 12 : 11, affinity: TextAffinity.upstream),
);
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump();
@ -3012,7 +3103,7 @@ void main() {
expectCupertinoSelectionToolbar();
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
@ -3150,7 +3241,7 @@ void main() {
// The toolbar is still showing.
expectCupertinoSelectionToolbar();
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
@ -3183,7 +3274,7 @@ void main() {
const TextSelection(baseOffset: 13, extentOffset: 23),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
@ -3230,7 +3321,51 @@ void main() {
// No toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets(
'tap after a double tap select is not affected (macOS)',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: SelectableText('Atwater Peel Sherbrooke Bonaventure'),
),
),
),
);
final Offset selectableTextStart = tester.getTopLeft(find.byType(SelectableText));
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
final TextEditingController controller = editableTextWidget.controller;
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
);
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(selectableTextStart + const Offset(100.0, 5.0));
await tester.pump();
// Collapse selection.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7),
);
// No toolbar.
expect(find.byType(CupertinoButton), findsNothing);
},
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
);
testWidgets(
@ -3265,7 +3400,7 @@ void main() {
expectCupertinoSelectionToolbar();
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
@ -3487,16 +3622,18 @@ void main() {
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
final TextEditingController controller = editableTextWidget.controller;
// We ended up moving the cursor to the edge of the same word and dismissed
// On iOS, we ended up moving the cursor to the edge of the same word and dismissed
// the toolbar.
//
// On macOS, we move the cursor to the tapped position.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
TextSelection.collapsed(offset: defaultTargetPlatform == TargetPlatform.iOS ? 7 : 4, affinity: TextAffinity.upstream),
);
expect(find.byType(CupertinoButton), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
@ -4029,7 +4166,7 @@ void main() {
expect(startHandleAfter.opacity.value, 0.0);
expect(endHandleAfter.opacity.value, 1.0);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }),
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }),
);
testWidgets(
@ -4098,10 +4235,10 @@ void main() {
final EditableText editableTextWidget = tester.widget(find.byType(EditableText).first);
final TextEditingController controller = editableTextWidget.controller;
// First tap moved the cursor to the beginning of the second word.
// First tap moves the cursor to the tapped position.
expect(
controller.selection,
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream),
);
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 500));
@ -4154,7 +4291,7 @@ void main() {
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
TextSelection.collapsed(offset: defaultTargetPlatform == TargetPlatform.iOS ? 12 : 11, affinity: TextAffinity.upstream),
);
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump();
@ -4166,7 +4303,7 @@ void main() {
);
expectCupertinoSelectionToolbar();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
@ -4191,7 +4328,7 @@ void main() {
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
TextSelection.collapsed(offset: defaultTargetPlatform == TargetPlatform.iOS ? 7 : 4, affinity: TextAffinity.upstream),
);
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
@ -4208,7 +4345,7 @@ void main() {
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 7, affinity: TextAffinity.upstream),
TextSelection.collapsed(offset: defaultTargetPlatform == TargetPlatform.iOS ? 7 : 1, affinity: TextAffinity.upstream),
);
await tester.tapAt(selectableTextStart + const Offset(10.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
@ -4230,7 +4367,7 @@ void main() {
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 12, affinity: TextAffinity.upstream),
TextSelection.collapsed(offset: defaultTargetPlatform == TargetPlatform.iOS ? 12 : 11, affinity: TextAffinity.upstream),
);
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
@ -4240,7 +4377,7 @@ void main() {
);
expectCupertinoSelectionToolbar();
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets('force press does not select a word on (android)', (WidgetTester tester) async {
@ -5318,7 +5455,7 @@ void main() {
expect(selection, isNotNull);
expect(selection!.baseOffset, 0);
expect(selection!.extentOffset, 1);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia }));
testWidgets('double tapping a space selects the previous word on mobile', (WidgetTester tester) async {
TextSelection? selection;