diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 0a050f229c66..93429ca864d8 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -52,6 +52,19 @@ class TextSelectionPoint { /// Direction of the text at this edge of the selection. final TextDirection? direction; + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is TextSelectionPoint + && other.point == point + && other.direction == direction; + } + @override String toString() { switch (direction) { @@ -63,6 +76,10 @@ class TextSelectionPoint { return '$point'; } } + + @override + int get hashCode => Object.hash(point, direction); + } /// The consecutive sequence of [TextPosition]s that the caret should move to diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index c858501ea958..0422a86cd977 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -467,6 +467,7 @@ class SelectableRegionState extends State with TextSelectionDe } void _handleTouchLongPressStart(LongPressStartDetails details) { + HapticFeedback.selectionClick(); widget.focusNode.requestFocus(); _selectWordAt(offset: details.globalPosition); _showToolbar(); @@ -537,6 +538,7 @@ class SelectableRegionState extends State with TextSelectionDe }, ); } + _stopSelectionStartEdgeUpdate(); _stopSelectionEndEdgeUpdate(); _updateSelectedContentIfNeeded(); } diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index d3dba89d1087..f2b0e6eba4d7 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -515,6 +515,11 @@ class TextSelectionOverlay { } _value = newValue; _updateSelectionOverlay(); + // _updateSelectionOverlay may not rebuild the selection overlay if the + // text metrics and selection doesn't change even if the text has changed. + // This rebuild is needed for the toolbar to update based on the latest text + // value. + _selectionOverlay.markNeedsBuild(); } void _updateSelectionOverlay() { @@ -541,7 +546,13 @@ class TextSelectionOverlay { /// /// This is intended to be called when the [renderObject] may have changed its /// text metrics (e.g. because the text was scrolled). - void updateForScroll() => _updateSelectionOverlay(); + void updateForScroll() { + _updateSelectionOverlay(); + // This method may be called due to windows metrics changes. In that case, + // non of the properties in _selectionOverlay will change, but a rebuild is + // still needed. + _selectionOverlay.markNeedsBuild(); + } /// Whether the handles are currently visible. bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible; @@ -1030,7 +1041,7 @@ class SelectionOverlay { return; } _startHandleType = value; - _markNeedsBuild(); + markNeedsBuild(); } /// The line height at the selection start. @@ -1045,9 +1056,11 @@ class SelectionOverlay { return; } _lineHeightAtStart = value; - _markNeedsBuild(); + markNeedsBuild(); } + bool _isDraggingStartHandle = false; + /// Whether the start handle is visible. /// /// If the value changes, the start handle uses [FadeTransition] to transition @@ -1059,6 +1072,12 @@ class SelectionOverlay { /// Called when the users start dragging the start selection handles. final ValueChanged? onStartHandleDragStart; + void _handleStartHandleDragStart(DragStartDetails details) { + assert(!_isDraggingStartHandle); + _isDraggingStartHandle = details.kind == PointerDeviceKind.touch; + onStartHandleDragStart?.call(details); + } + /// Called when the users drag the start selection handles to new locations. final ValueChanged? onStartHandleDragUpdate; @@ -1066,6 +1085,11 @@ class SelectionOverlay { /// handles. final ValueChanged? onStartHandleDragEnd; + void _handleStartHandleDragEnd(DragEndDetails details) { + _isDraggingStartHandle = false; + onStartHandleDragEnd?.call(details); + } + /// The type of end selection handle. /// /// Changing the value while the handles are visible causes them to rebuild. @@ -1076,7 +1100,7 @@ class SelectionOverlay { return; } _endHandleType = value; - _markNeedsBuild(); + markNeedsBuild(); } /// The line height at the selection end. @@ -1091,9 +1115,11 @@ class SelectionOverlay { return; } _lineHeightAtEnd = value; - _markNeedsBuild(); + markNeedsBuild(); } + bool _isDraggingEndHandle = false; + /// Whether the end handle is visible. /// /// If the value changes, the end handle uses [FadeTransition] to transition @@ -1105,6 +1131,12 @@ class SelectionOverlay { /// Called when the users start dragging the end selection handles. final ValueChanged? onEndHandleDragStart; + void _handleEndHandleDragStart(DragStartDetails details) { + assert(!_isDraggingEndHandle); + _isDraggingEndHandle = details.kind == PointerDeviceKind.touch; + onEndHandleDragStart?.call(details); + } + /// Called when the users drag the end selection handles to new locations. final ValueChanged? onEndHandleDragUpdate; @@ -1112,6 +1144,11 @@ class SelectionOverlay { /// handles. final ValueChanged? onEndHandleDragEnd; + void _handleEndHandleDragEnd(DragEndDetails details) { + _isDraggingEndHandle = false; + onEndHandleDragEnd?.call(details); + } + /// Whether the toolbar is visible. /// /// If the value changes, the toolbar uses [FadeTransition] to transition @@ -1125,7 +1162,21 @@ class SelectionOverlay { List _selectionEndpoints; set selectionEndpoints(List value) { if (!listEquals(_selectionEndpoints, value)) { - _markNeedsBuild(); + markNeedsBuild(); + if ((_isDraggingEndHandle || _isDraggingStartHandle) && + _startHandleType != TextSelectionHandleType.collapsed) { + switch(defaultTargetPlatform) { + case TargetPlatform.android: + HapticFeedback.selectionClick(); + break; + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + } + } } _selectionEndpoints = value; } @@ -1220,7 +1271,7 @@ class SelectionOverlay { return; } _toolbarLocation = value; - _markNeedsBuild(); + markNeedsBuild(); } /// Controls the fade-in and fade-out animations for the toolbar and handles. @@ -1250,7 +1301,6 @@ class SelectionOverlay { OverlayEntry(builder: _buildStartHandle), OverlayEntry(builder: _buildEndHandle), ]; - Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insertAll(_handles!); } @@ -1299,7 +1349,9 @@ class SelectionOverlay { } bool _buildScheduled = false; - void _markNeedsBuild() { + + /// Rebuilds the selection toolbar or handles if they are present. + void markNeedsBuild() { if (_handles == null && _toolbar == null) { return; } @@ -1379,9 +1431,9 @@ class SelectionOverlay { type: _startHandleType, handleLayerLink: startHandleLayerLink, onSelectionHandleTapped: onSelectionHandleTapped, - onSelectionHandleDragStart: onStartHandleDragStart, + onSelectionHandleDragStart: _handleStartHandleDragStart, onSelectionHandleDragUpdate: onStartHandleDragUpdate, - onSelectionHandleDragEnd: onStartHandleDragEnd, + onSelectionHandleDragEnd: _handleStartHandleDragEnd, selectionControls: selectionControls, visibility: startHandlesVisible, preferredLineHeight: _lineHeightAtStart, @@ -1406,9 +1458,9 @@ class SelectionOverlay { type: _endHandleType, handleLayerLink: endHandleLayerLink, onSelectionHandleTapped: onSelectionHandleTapped, - onSelectionHandleDragStart: onEndHandleDragStart, + onSelectionHandleDragStart: _handleEndHandleDragStart, onSelectionHandleDragUpdate: onEndHandleDragUpdate, - onSelectionHandleDragEnd: onEndHandleDragEnd, + onSelectionHandleDragEnd: _handleEndHandleDragEnd, selectionControls: selectionControls, visibility: endHandlesVisible, preferredLineHeight: _lineHeightAtEnd, diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index f4e3701e0d5a..71ade47d640b 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -2477,6 +2477,61 @@ void main() { variant: TargetPlatformVariant.all(excluding: { TargetPlatform.iOS, TargetPlatform.macOS }), ); + testWidgets('Drag handles trigger feedback', (WidgetTester tester) async { + final FeedbackTester feedback = FeedbackTester(); + addTearDown(feedback.dispose); + final TextEditingController controller = TextEditingController(); + await tester.pumpWidget( + overlay( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ); + + const String testValue = 'abc def ghi'; + await tester.enterText(find.byType(TextField), testValue); + expect(feedback.hapticCount, 0); + await skipPastScrollingAnimation(tester); + + // Long press the 'e' to select 'def'. + final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); + TestGesture gesture = await tester.startGesture(ePos, pointer: 7); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero + + final TextSelection selection = controller.selection; + expect(selection.baseOffset, 4); + expect(selection.extentOffset, 7); + expect(feedback.hapticCount, 1); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(selection), + renderEditable, + ); + expect(endpoints.length, 2); + + // Drag the right handle 2 letters to the right. + // Use a small offset because the endpoint is on the very corner + // of the handle. + final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0); + final Offset newHandlePos = textOffsetToPosition(tester, testValue.length); + gesture = await tester.startGesture(handlePos, pointer: 7); + await tester.pump(); + await gesture.moveTo(newHandlePos); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(controller.selection.baseOffset, 4); + expect(controller.selection.extentOffset, 11); + expect(feedback.hapticCount, 2); + }); + testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async { final TextEditingController controller = TextEditingController(); @@ -4965,6 +5020,7 @@ void main() { testWidgets('haptic feedback', (WidgetTester tester) async { final FeedbackTester feedback = FeedbackTester(); + addTearDown(feedback.dispose); final TextEditingController controller = TextEditingController(); await tester.pumpWidget( @@ -4987,8 +5043,6 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 1)); expect(feedback.clickSoundCount, 0); expect(feedback.hapticCount, 1); - - feedback.dispose(); }); testWidgets('Text field drops selection color when losing focus', (WidgetTester tester) async { diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index eb2c0ce1a239..06839cb169ec 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -915,6 +915,20 @@ void main() { expect(endpoints[0].point.dx, 0); }); + test('TextSelectionPoint can compare', () { + // ignore: prefer_const_constructors + final TextSelectionPoint first = TextSelectionPoint(Offset(1, 2), TextDirection.ltr); + // ignore: prefer_const_constructors + final TextSelectionPoint second = TextSelectionPoint(Offset(1, 2), TextDirection.ltr); + expect(first == second, isTrue); + expect(first.hashCode == second.hashCode, isTrue); + + // ignore: prefer_const_constructors + final TextSelectionPoint different = TextSelectionPoint(Offset(2, 2), TextDirection.ltr); + expect(first == different, isFalse); + expect(first.hashCode == different.hashCode, isFalse); + }); + group('getRectForComposingRange', () { const TextSpan emptyTextSpan = TextSpan(text: '\u200e'); final TextSelectionDelegate delegate = _FakeEditableTextState(); diff --git a/packages/flutter/test/widgets/scrollable_selection_test.dart b/packages/flutter/test/widgets/scrollable_selection_test.dart index 670d1f744506..67461d226107 100644 --- a/packages/flutter/test/widgets/scrollable_selection_test.dart +++ b/packages/flutter/test/widgets/scrollable_selection_test.dart @@ -463,6 +463,7 @@ void main() { final List boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]); expect(boxes.length, 1); + // Find end handle. final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph0); await gesture.down(handlePos); @@ -493,6 +494,113 @@ void main() { await gesture.up(); }); + testWidgets('select to scroll by dragging start selection handle stops scroll when released', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + await tester.pumpAndSettle(); + + // Long press to bring up the selection handles. + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2)); + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); + + final List boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]); + expect(boxes.length, 1); + // Find start handle. + final Offset handlePos = globalize(boxes[0].toRect().bottomLeft, paragraph0); + await gesture.down(handlePos); + + expect(controller.offset, 0.0); + double previousOffset = controller.offset; + // Scrollable only auto scroll if the drag passes the boundary. + await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + previousOffset = controller.offset; + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + previousOffset = controller.offset; + + // Release handle should stop scrolling. + await gesture.up(); + // Last scheduled scroll. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + previousOffset = controller.offset; + await tester.pumpAndSettle(); + expect(controller.offset, previousOffset); + }); + + testWidgets('select to scroll by dragging end selection handle stops scroll when released', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: SelectionArea( + selectionControls: materialTextSelectionControls, + child: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Item $index'); + }, + ), + ), + )); + await tester.pumpAndSettle(); + + // Long press to bring up the selection handles. + final RenderParagraph paragraph0 = tester.renderObject(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph0, 2)); + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + expect(paragraph0.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4)); + + final List boxes = paragraph0.getBoxesForSelection(paragraph0.selections[0]); + expect(boxes.length, 1); + final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph0); + await gesture.down(handlePos); + + expect(controller.offset, 0.0); + double previousOffset = controller.offset; + // Scrollable only auto scroll if the drag passes the boundary + await gesture.moveTo(tester.getBottomRight(find.byType(ListView)) + const Offset(0, 40)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + previousOffset = controller.offset; + + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.offset > previousOffset, isTrue); + previousOffset = controller.offset; + + // Release handle should stop scrolling. + await gesture.up(); + // Last scheduled scroll. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + previousOffset = controller.offset; + await tester.pumpAndSettle(); + expect(controller.offset, previousOffset); + }); + testWidgets('keyboard selection should auto scroll - vertical', (WidgetTester tester) async { final FocusNode node = FocusNode(); final ScrollController controller = ScrollController(); diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index 4eb458857ef6..9d3fb4700919 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -298,6 +298,67 @@ void main() { }); }); + testWidgets('dragging handle or selecting word triggers haptic feedback on Android', (WidgetTester tester) async { + final List log = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall); + }); + + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: FocusNode(), + selectionControls: materialTextSelectionControls, + child: const Text('How are you?'), + ), + ), + ); + + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 6)); // at the 'r' + addTearDown(gesture.removePointer); + await tester.pump(const Duration(milliseconds: 500)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 500)); + // `are` is selected. + expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7)); + expect( + log.last, + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), + ); + log.clear(); + final List boxes = paragraph.getBoxesForSelection(paragraph.selections[0]); + expect(boxes.length, 1); + final Offset handlePos = globalize(boxes[0].toRect().bottomRight, paragraph); + await gesture.down(handlePos); + final Offset endPos = Offset(textOffsetToPosition(paragraph, 8).dx, handlePos.dy); + + // Select 1 more character by dragging end handle to trigger feedback. + await gesture.moveTo(endPos); + expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 8)); + // Only Android vibrate when dragging the handle. + switch(defaultTargetPlatform) { + case TargetPlatform.android: + expect( + log.last, + isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.selectionClick'), + ); + break; + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(log, isEmpty); + break; + } + await gesture.up(); + }, variant: TargetPlatformVariant.all()); + group('SelectionArea integration', () { testWidgets('mouse can select single text', (WidgetTester tester) async { await tester.pumpWidget(