Skip to content

Commit c2b2950

Browse files
authored
Add selection feedback for both selection area and text field (#115373)
* Add selection feedback for both selection area and text field * Addressing comment * Fixes more test
1 parent a1ea383 commit c2b2950

File tree

7 files changed

+323
-15
lines changed

7 files changed

+323
-15
lines changed

packages/flutter/lib/src/rendering/editable.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ class TextSelectionPoint {
5252
/// Direction of the text at this edge of the selection.
5353
final TextDirection? direction;
5454

55+
@override
56+
bool operator ==(Object other) {
57+
if (identical(this, other)) {
58+
return true;
59+
}
60+
if (other.runtimeType != runtimeType) {
61+
return false;
62+
}
63+
return other is TextSelectionPoint
64+
&& other.point == point
65+
&& other.direction == direction;
66+
}
67+
5568
@override
5669
String toString() {
5770
switch (direction) {
@@ -63,6 +76,10 @@ class TextSelectionPoint {
6376
return '$point';
6477
}
6578
}
79+
80+
@override
81+
int get hashCode => Object.hash(point, direction);
82+
6683
}
6784

6885
/// The consecutive sequence of [TextPosition]s that the caret should move to

packages/flutter/lib/src/widgets/selectable_region.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
467467
}
468468

469469
void _handleTouchLongPressStart(LongPressStartDetails details) {
470+
HapticFeedback.selectionClick();
470471
widget.focusNode.requestFocus();
471472
_selectWordAt(offset: details.globalPosition);
472473
_showToolbar();
@@ -537,6 +538,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
537538
},
538539
);
539540
}
541+
_stopSelectionStartEdgeUpdate();
540542
_stopSelectionEndEdgeUpdate();
541543
_updateSelectedContentIfNeeded();
542544
}

packages/flutter/lib/src/widgets/text_selection.dart

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,11 @@ class TextSelectionOverlay {
515515
}
516516
_value = newValue;
517517
_updateSelectionOverlay();
518+
// _updateSelectionOverlay may not rebuild the selection overlay if the
519+
// text metrics and selection doesn't change even if the text has changed.
520+
// This rebuild is needed for the toolbar to update based on the latest text
521+
// value.
522+
_selectionOverlay.markNeedsBuild();
518523
}
519524

520525
void _updateSelectionOverlay() {
@@ -541,7 +546,13 @@ class TextSelectionOverlay {
541546
///
542547
/// This is intended to be called when the [renderObject] may have changed its
543548
/// text metrics (e.g. because the text was scrolled).
544-
void updateForScroll() => _updateSelectionOverlay();
549+
void updateForScroll() {
550+
_updateSelectionOverlay();
551+
// This method may be called due to windows metrics changes. In that case,
552+
// non of the properties in _selectionOverlay will change, but a rebuild is
553+
// still needed.
554+
_selectionOverlay.markNeedsBuild();
555+
}
545556

546557
/// Whether the handles are currently visible.
547558
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
@@ -1030,7 +1041,7 @@ class SelectionOverlay {
10301041
return;
10311042
}
10321043
_startHandleType = value;
1033-
_markNeedsBuild();
1044+
markNeedsBuild();
10341045
}
10351046

10361047
/// The line height at the selection start.
@@ -1045,9 +1056,11 @@ class SelectionOverlay {
10451056
return;
10461057
}
10471058
_lineHeightAtStart = value;
1048-
_markNeedsBuild();
1059+
markNeedsBuild();
10491060
}
10501061

1062+
bool _isDraggingStartHandle = false;
1063+
10511064
/// Whether the start handle is visible.
10521065
///
10531066
/// If the value changes, the start handle uses [FadeTransition] to transition
@@ -1059,13 +1072,24 @@ class SelectionOverlay {
10591072
/// Called when the users start dragging the start selection handles.
10601073
final ValueChanged<DragStartDetails>? onStartHandleDragStart;
10611074

1075+
void _handleStartHandleDragStart(DragStartDetails details) {
1076+
assert(!_isDraggingStartHandle);
1077+
_isDraggingStartHandle = details.kind == PointerDeviceKind.touch;
1078+
onStartHandleDragStart?.call(details);
1079+
}
1080+
10621081
/// Called when the users drag the start selection handles to new locations.
10631082
final ValueChanged<DragUpdateDetails>? onStartHandleDragUpdate;
10641083

10651084
/// Called when the users lift their fingers after dragging the start selection
10661085
/// handles.
10671086
final ValueChanged<DragEndDetails>? onStartHandleDragEnd;
10681087

1088+
void _handleStartHandleDragEnd(DragEndDetails details) {
1089+
_isDraggingStartHandle = false;
1090+
onStartHandleDragEnd?.call(details);
1091+
}
1092+
10691093
/// The type of end selection handle.
10701094
///
10711095
/// Changing the value while the handles are visible causes them to rebuild.
@@ -1076,7 +1100,7 @@ class SelectionOverlay {
10761100
return;
10771101
}
10781102
_endHandleType = value;
1079-
_markNeedsBuild();
1103+
markNeedsBuild();
10801104
}
10811105

10821106
/// The line height at the selection end.
@@ -1091,9 +1115,11 @@ class SelectionOverlay {
10911115
return;
10921116
}
10931117
_lineHeightAtEnd = value;
1094-
_markNeedsBuild();
1118+
markNeedsBuild();
10951119
}
10961120

1121+
bool _isDraggingEndHandle = false;
1122+
10971123
/// Whether the end handle is visible.
10981124
///
10991125
/// If the value changes, the end handle uses [FadeTransition] to transition
@@ -1105,13 +1131,24 @@ class SelectionOverlay {
11051131
/// Called when the users start dragging the end selection handles.
11061132
final ValueChanged<DragStartDetails>? onEndHandleDragStart;
11071133

1134+
void _handleEndHandleDragStart(DragStartDetails details) {
1135+
assert(!_isDraggingEndHandle);
1136+
_isDraggingEndHandle = details.kind == PointerDeviceKind.touch;
1137+
onEndHandleDragStart?.call(details);
1138+
}
1139+
11081140
/// Called when the users drag the end selection handles to new locations.
11091141
final ValueChanged<DragUpdateDetails>? onEndHandleDragUpdate;
11101142

11111143
/// Called when the users lift their fingers after dragging the end selection
11121144
/// handles.
11131145
final ValueChanged<DragEndDetails>? onEndHandleDragEnd;
11141146

1147+
void _handleEndHandleDragEnd(DragEndDetails details) {
1148+
_isDraggingEndHandle = false;
1149+
onEndHandleDragEnd?.call(details);
1150+
}
1151+
11151152
/// Whether the toolbar is visible.
11161153
///
11171154
/// If the value changes, the toolbar uses [FadeTransition] to transition
@@ -1125,7 +1162,21 @@ class SelectionOverlay {
11251162
List<TextSelectionPoint> _selectionEndpoints;
11261163
set selectionEndpoints(List<TextSelectionPoint> value) {
11271164
if (!listEquals(_selectionEndpoints, value)) {
1128-
_markNeedsBuild();
1165+
markNeedsBuild();
1166+
if ((_isDraggingEndHandle || _isDraggingStartHandle) &&
1167+
_startHandleType != TextSelectionHandleType.collapsed) {
1168+
switch(defaultTargetPlatform) {
1169+
case TargetPlatform.android:
1170+
HapticFeedback.selectionClick();
1171+
break;
1172+
case TargetPlatform.fuchsia:
1173+
case TargetPlatform.iOS:
1174+
case TargetPlatform.linux:
1175+
case TargetPlatform.macOS:
1176+
case TargetPlatform.windows:
1177+
break;
1178+
}
1179+
}
11291180
}
11301181
_selectionEndpoints = value;
11311182
}
@@ -1220,7 +1271,7 @@ class SelectionOverlay {
12201271
return;
12211272
}
12221273
_toolbarLocation = value;
1223-
_markNeedsBuild();
1274+
markNeedsBuild();
12241275
}
12251276

12261277
/// Controls the fade-in and fade-out animations for the toolbar and handles.
@@ -1250,7 +1301,6 @@ class SelectionOverlay {
12501301
OverlayEntry(builder: _buildStartHandle),
12511302
OverlayEntry(builder: _buildEndHandle),
12521303
];
1253-
12541304
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insertAll(_handles!);
12551305
}
12561306

@@ -1299,7 +1349,9 @@ class SelectionOverlay {
12991349
}
13001350

13011351
bool _buildScheduled = false;
1302-
void _markNeedsBuild() {
1352+
1353+
/// Rebuilds the selection toolbar or handles if they are present.
1354+
void markNeedsBuild() {
13031355
if (_handles == null && _toolbar == null) {
13041356
return;
13051357
}
@@ -1379,9 +1431,9 @@ class SelectionOverlay {
13791431
type: _startHandleType,
13801432
handleLayerLink: startHandleLayerLink,
13811433
onSelectionHandleTapped: onSelectionHandleTapped,
1382-
onSelectionHandleDragStart: onStartHandleDragStart,
1434+
onSelectionHandleDragStart: _handleStartHandleDragStart,
13831435
onSelectionHandleDragUpdate: onStartHandleDragUpdate,
1384-
onSelectionHandleDragEnd: onStartHandleDragEnd,
1436+
onSelectionHandleDragEnd: _handleStartHandleDragEnd,
13851437
selectionControls: selectionControls,
13861438
visibility: startHandlesVisible,
13871439
preferredLineHeight: _lineHeightAtStart,
@@ -1406,9 +1458,9 @@ class SelectionOverlay {
14061458
type: _endHandleType,
14071459
handleLayerLink: endHandleLayerLink,
14081460
onSelectionHandleTapped: onSelectionHandleTapped,
1409-
onSelectionHandleDragStart: onEndHandleDragStart,
1461+
onSelectionHandleDragStart: _handleEndHandleDragStart,
14101462
onSelectionHandleDragUpdate: onEndHandleDragUpdate,
1411-
onSelectionHandleDragEnd: onEndHandleDragEnd,
1463+
onSelectionHandleDragEnd: _handleEndHandleDragEnd,
14121464
selectionControls: selectionControls,
14131465
visibility: endHandlesVisible,
14141466
preferredLineHeight: _lineHeightAtEnd,

packages/flutter/test/material/text_field_test.dart

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2477,6 +2477,61 @@ void main() {
24772477
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
24782478
);
24792479

2480+
testWidgets('Drag handles trigger feedback', (WidgetTester tester) async {
2481+
final FeedbackTester feedback = FeedbackTester();
2482+
addTearDown(feedback.dispose);
2483+
final TextEditingController controller = TextEditingController();
2484+
await tester.pumpWidget(
2485+
overlay(
2486+
child: TextField(
2487+
dragStartBehavior: DragStartBehavior.down,
2488+
controller: controller,
2489+
),
2490+
),
2491+
);
2492+
2493+
const String testValue = 'abc def ghi';
2494+
await tester.enterText(find.byType(TextField), testValue);
2495+
expect(feedback.hapticCount, 0);
2496+
await skipPastScrollingAnimation(tester);
2497+
2498+
// Long press the 'e' to select 'def'.
2499+
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
2500+
TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
2501+
await tester.pump(const Duration(seconds: 2));
2502+
await gesture.up();
2503+
await tester.pump();
2504+
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
2505+
2506+
final TextSelection selection = controller.selection;
2507+
expect(selection.baseOffset, 4);
2508+
expect(selection.extentOffset, 7);
2509+
expect(feedback.hapticCount, 1);
2510+
2511+
final RenderEditable renderEditable = findRenderEditable(tester);
2512+
final List<TextSelectionPoint> endpoints = globalize(
2513+
renderEditable.getEndpointsForSelection(selection),
2514+
renderEditable,
2515+
);
2516+
expect(endpoints.length, 2);
2517+
2518+
// Drag the right handle 2 letters to the right.
2519+
// Use a small offset because the endpoint is on the very corner
2520+
// of the handle.
2521+
final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
2522+
final Offset newHandlePos = textOffsetToPosition(tester, testValue.length);
2523+
gesture = await tester.startGesture(handlePos, pointer: 7);
2524+
await tester.pump();
2525+
await gesture.moveTo(newHandlePos);
2526+
await tester.pump();
2527+
await gesture.up();
2528+
await tester.pump();
2529+
2530+
expect(controller.selection.baseOffset, 4);
2531+
expect(controller.selection.extentOffset, 11);
2532+
expect(feedback.hapticCount, 2);
2533+
});
2534+
24802535
testWidgets('Cannot drag one handle past the other', (WidgetTester tester) async {
24812536
final TextEditingController controller = TextEditingController();
24822537

@@ -4965,6 +5020,7 @@ void main() {
49655020

49665021
testWidgets('haptic feedback', (WidgetTester tester) async {
49675022
final FeedbackTester feedback = FeedbackTester();
5023+
addTearDown(feedback.dispose);
49685024
final TextEditingController controller = TextEditingController();
49695025

49705026
await tester.pumpWidget(
@@ -4987,8 +5043,6 @@ void main() {
49875043
await tester.pumpAndSettle(const Duration(seconds: 1));
49885044
expect(feedback.clickSoundCount, 0);
49895045
expect(feedback.hapticCount, 1);
4990-
4991-
feedback.dispose();
49925046
});
49935047

49945048
testWidgets('Text field drops selection color when losing focus', (WidgetTester tester) async {

packages/flutter/test/rendering/editable_test.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,20 @@ void main() {
915915
expect(endpoints[0].point.dx, 0);
916916
});
917917

918+
test('TextSelectionPoint can compare', () {
919+
// ignore: prefer_const_constructors
920+
final TextSelectionPoint first = TextSelectionPoint(Offset(1, 2), TextDirection.ltr);
921+
// ignore: prefer_const_constructors
922+
final TextSelectionPoint second = TextSelectionPoint(Offset(1, 2), TextDirection.ltr);
923+
expect(first == second, isTrue);
924+
expect(first.hashCode == second.hashCode, isTrue);
925+
926+
// ignore: prefer_const_constructors
927+
final TextSelectionPoint different = TextSelectionPoint(Offset(2, 2), TextDirection.ltr);
928+
expect(first == different, isFalse);
929+
expect(first.hashCode == different.hashCode, isFalse);
930+
});
931+
918932
group('getRectForComposingRange', () {
919933
const TextSpan emptyTextSpan = TextSpan(text: '\u200e');
920934
final TextSelectionDelegate delegate = _FakeEditableTextState();

0 commit comments

Comments
 (0)