diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 2ddc197ceb6..0fc2a87e1b9 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -86,33 +86,7 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete @override void onSingleTapUp(TapUpDetails details) { editableText.hideToolbar(); - if (delegate.selectionEnabled) { - switch (Theme.of(_state.context).platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - switch (details.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - // Precise devices should place the cursor at a precise position. - renderEditable.selectPosition(cause: SelectionChangedCause.tap); - break; - case PointerDeviceKind.touch: - case PointerDeviceKind.unknown: - // On macOS/iOS/iPadOS a touch tap places the cursor at the edge - // of the word. - renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); - break; - } - break; - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - renderEditable.selectPosition(cause: SelectionChangedCause.tap); - break; - } - } + super.onSingleTapUp(details); _state._requestKeyboard(); _state.widget.onTap?.call(); } diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index c4c5154a440..4a24bf6279e 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -946,6 +946,65 @@ class TextSelectionGestureDetectorBuilder { && renderEditable.selection!.end >= textPosition.offset; } + // Expand the selection to the given global position. + // + // Either base or extent will be moved to the last tapped position, whichever + // is closest. The selection will never shrink or pivot, only grow. + // + // See also: + // + // * [_extendSelection], which is similar but pivots the selection around + // the base. + void _expandSelection(Offset offset, SelectionChangedCause cause) { + assert(cause != null); + assert(offset != null); + assert(renderEditable.selection?.baseOffset != null); + + final TextPosition tappedPosition = renderEditable.getPositionForPoint(offset); + final TextSelection selection = renderEditable.selection!; + final bool baseIsCloser = + (tappedPosition.offset - selection.baseOffset).abs() + < (tappedPosition.offset - selection.extentOffset).abs(); + final TextSelection nextSelection = selection.copyWith( + baseOffset: baseIsCloser ? selection.extentOffset : selection.baseOffset, + extentOffset: tappedPosition.offset, + ); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: nextSelection, + ), + cause, + ); + } + + // Extend the selection to the given global position. + // + // Holds the base in place and moves the extent. + // + // See also: + // + // * [_expandSelection], which is similar but always increases the size of + // the selection. + void _extendSelection(Offset offset, SelectionChangedCause cause) { + assert(cause != null); + assert(offset != null); + assert(renderEditable.selection?.baseOffset != null); + + final TextPosition tappedPosition = renderEditable.getPositionForPoint(offset); + final TextSelection selection = renderEditable.selection!; + final TextSelection nextSelection = selection.copyWith( + extentOffset: tappedPosition.offset, + ); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: nextSelection, + ), + cause, + ); + } + /// Whether to show the selection toolbar. /// /// It is based on the signal source when a [onTapDown] is called. This getter @@ -964,9 +1023,12 @@ class TextSelectionGestureDetectorBuilder { @protected RenderEditable get renderEditable => editableText.renderEditable; - /// The viewport offset pixels of the [RenderEditable] at the last drag start. + // The viewport offset pixels of the [RenderEditable] at the last drag start. double _dragStartViewportOffset = 0.0; + // True iff a tap + shift has been detected but the tap has not yet come up. + bool _isShiftTapping = false; + /// Handler for [TextSelectionGestureDetector.onTapDown]. /// /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets @@ -986,6 +1048,28 @@ class TextSelectionGestureDetectorBuilder { _shouldShowSelectionToolbar = kind == null || kind == PointerDeviceKind.touch || kind == PointerDeviceKind.stylus; + + // Handle shift + click selection if needed. + final bool isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed + .any({ + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + }.contains); + if (isShiftPressed && renderEditable.selection?.baseOffset != null) { + _isShiftTapping = true; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + _expandSelection(details.globalPosition, SelectionChangedCause.tap); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + break; + } + } } /// Handler for [TextSelectionGestureDetector.onForcePressStart]. @@ -1043,8 +1127,37 @@ class TextSelectionGestureDetectorBuilder { /// this callback. @protected void onSingleTapUp(TapUpDetails details) { + if (_isShiftTapping) { + _isShiftTapping = false; + return; + } + if (delegate.selectionEnabled) { - renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + // Precise devices should place the cursor at a precise position. + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + // On macOS/iOS/iPadOS a touch tap places the cursor at the edge + // of the word. + renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); + break; + } + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + } } } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index d1672659c84..ad70510fd76 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -16,7 +16,7 @@ import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Color; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind, kSecondaryMouseButton; +import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind, kSecondaryMouseButton, kDoubleTapTimeout; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1672,8 +1672,7 @@ void main() { // But don't trigger the toolbar. expect(find.byType(CupertinoButton), findsNothing); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'slow double tap does not trigger double tap', @@ -1706,8 +1705,7 @@ void main() { // No toolbar. expect(find.byType(CupertinoButton), findsNothing); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'double tap selects word and first tap of double tap moves cursor', @@ -1807,8 +1805,7 @@ void main() { // Selected text shows 3 toolbar buttons. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'double tap hold selects word', @@ -1897,8 +1894,7 @@ void main() { // No toolbar. expect(find.byType(CupertinoButton), findsNothing); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('double tapping a space selects the previous word on iOS', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( @@ -2292,8 +2288,7 @@ void main() { // The toolbar from the long press is now dismissed by the second tap. expect(find.byType(CupertinoButton), findsNothing); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'long press drag moves the cursor under the drag and shows toolbar on lift', @@ -2485,8 +2480,7 @@ void main() { // Long press toolbar. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2)); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'double tap after a long tap is not affected', @@ -2526,8 +2520,7 @@ void main() { ); // Shows toolbar. expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'double tap chains work', @@ -2591,8 +2584,7 @@ void main() { const TextSelection(baseOffset: 8, extentOffset: 12), ); expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3)); - }, - ); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('force press selects word', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( @@ -2673,7 +2665,7 @@ void main() { await tester.pump(); // Falling back to a single tap doesn't trigger a toolbar. expect(find.byType(CupertinoButton), findsNothing); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( @@ -2735,7 +2727,7 @@ void main() { // The selection doesn't move beyond the left handle. There's always at // least 1 char selected. expect(controller.selection.extentOffset, 5); - }); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); @@ -4492,7 +4484,10 @@ void main() { find.byType(CupertinoApp), matchesGoldenFile('text_field_golden.TextSelectionStyle.1.png'), ); - }); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS }), + skip: kIsWeb, // [intended] the web has its own Select All. + ); testWidgets('text selection style 2', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( @@ -4538,7 +4533,10 @@ void main() { find.byType(CupertinoApp), matchesGoldenFile('text_field_golden.TextSelectionStyle.2.png'), ); - }); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS }), + skip: kIsWeb, // [intended] the web has its own Select All. + ); testWidgets('textSelectionControls is passed to EditableText', (WidgetTester tester) async { final MockTextSelectionControls selectionControl = MockTextSelectionControls(); @@ -4877,4 +4875,92 @@ void main() { matchesGoldenFile('overflow_clipbehavior_none.cupertino.0.png'), ); }); + + testWidgets('can shift + tap to select with a keyboard (Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('can shift + tap to select with a keyboard (non-Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoTextField( + controller: controller, + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 1dc8fd394c2..1ce97f63628 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -10512,4 +10512,92 @@ void main() { await tester.pump(state.cursorBlinkInterval); expect(editable.showCursor.value, isTrue); }); + + testWidgets('can shift + tap to select with a keyboard (Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller), + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 23); + expect(controller.selection.extentOffset, 4); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + + testWidgets('can shift + tap to select with a keyboard (non-Apple platforms)', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'Atwater Peel Sherbrooke Bonaventure', + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: TextField(controller: controller), + ), + ), + ), + ); + + await tester.tapAt(textOffsetToPosition(tester, 13)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 13); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 20)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 20); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 23)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 23); + + await tester.pump(kDoubleTapTimeout); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.tapAt(textOffsetToPosition(tester, 4)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + expect(controller.selection.baseOffset, 13); + expect(controller.selection.extentOffset, 4); + }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); } diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 04f7327dde3..4b601acdbcc 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/gestures.dart' show PointerDeviceKind, kSecondaryButton; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -506,8 +507,20 @@ void main() { final FakeEditableTextState state = tester.state(find.byType(FakeEditableText)); final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable)); expect(state.showToolbarCalled, isFalse); - expect(renderEditable.selectWordEdgeCalled, isTrue); - }); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect(renderEditable.selectWordEdgeCalled, isTrue); + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect(renderEditable.selectPositionAtCalled, isTrue); + break; + } + }, variant: TargetPlatformVariant.all()); testWidgets('test TextSelectionGestureDetectorBuilder double tap', (WidgetTester tester) async { await pumpTextSelectionGestureDetectorBuilder(tester);