diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart index 53a34289fc..12f3a996e1 100644 --- a/packages/flutter/lib/src/material/selectable_text.dart +++ b/packages/flutter/lib/src/material/selectable_text.dart @@ -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 implements TextSelectio textSpan: widget.textSpan ?? TextSpan(text: widget.data), ); _controller.addListener(_onControllerChanged); + _effectiveFocusNode.addListener(_handleFocusChanged); } @override @@ -595,6 +598,10 @@ class _SelectableTextState extends State 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 implements TextSelectio @override void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); _focusNode?.dispose(); _controller.dispose(); super.dispose(); @@ -620,6 +628,20 @@ class _SelectableTextState extends State 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) { diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index ed5746b090..cab5f7fe30 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -1475,6 +1475,59 @@ void main() { expect(topLeft.dx, equals(399.0)); }); + testWidgets('Tapping outside SelectableText clears the selection', (WidgetTester tester) async { + Future 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: [ + 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.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.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.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.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.iOS, TargetPlatform.macOS }), + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( @@ -3150,7 +3241,7 @@ void main() { // The toolbar is still showing. expectCupertinoSelectionToolbar(); }, - variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( @@ -3183,7 +3274,7 @@ void main() { const TextSelection(baseOffset: 13, extentOffset: 23), ); }, - variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), + variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( @@ -3230,7 +3321,51 @@ void main() { // No toolbar. expect(find.byType(CupertinoButton), findsNothing); }, - variant: const TargetPlatformVariant({ 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.iOS, TargetPlatform.macOS }), + variant: const TargetPlatformVariant({ 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.iOS, TargetPlatform.macOS }), + variant: const TargetPlatformVariant({ 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.iOS, TargetPlatform.android }), + variant: const TargetPlatformVariant({ 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.iOS, TargetPlatform.macOS }), + }, variant: const TargetPlatformVariant({ 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.iOS, TargetPlatform.macOS }), + variant: const TargetPlatformVariant({ 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.macOS, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia })); + }, variant: const TargetPlatformVariant({ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia })); testWidgets('double tapping a space selects the previous word on mobile', (WidgetTester tester) async { TextSelection? selection;