Skip to content

Commit

Permalink
Add selection feedback for both selection area and text field (#115373)
Browse files Browse the repository at this point in the history
* Add selection feedback for both selection area and text field

* Addressing comment

* Fixes more test
  • Loading branch information
chunhtai authored Nov 17, 2022
1 parent a1ea383 commit c2b2950
Show file tree
Hide file tree
Showing 7 changed files with 323 additions and 15 deletions.
17 changes: 17 additions & 0 deletions packages/flutter/lib/src/rendering/editable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/flutter/lib/src/widgets/selectable_region.dart
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
}

void _handleTouchLongPressStart(LongPressStartDetails details) {
HapticFeedback.selectionClick();
widget.focusNode.requestFocus();
_selectWordAt(offset: details.globalPosition);
_showToolbar();
Expand Down Expand Up @@ -537,6 +538,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
},
);
}
_stopSelectionStartEdgeUpdate();
_stopSelectionEndEdgeUpdate();
_updateSelectedContentIfNeeded();
}
Expand Down
78 changes: 65 additions & 13 deletions packages/flutter/lib/src/widgets/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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;
Expand Down Expand Up @@ -1030,7 +1041,7 @@ class SelectionOverlay {
return;
}
_startHandleType = value;
_markNeedsBuild();
markNeedsBuild();
}

/// The line height at the selection start.
Expand All @@ -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
Expand All @@ -1059,13 +1072,24 @@ class SelectionOverlay {
/// Called when the users start dragging the start selection handles.
final ValueChanged<DragStartDetails>? 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<DragUpdateDetails>? onStartHandleDragUpdate;

/// Called when the users lift their fingers after dragging the start selection
/// handles.
final ValueChanged<DragEndDetails>? 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.
Expand All @@ -1076,7 +1100,7 @@ class SelectionOverlay {
return;
}
_endHandleType = value;
_markNeedsBuild();
markNeedsBuild();
}

/// The line height at the selection end.
Expand All @@ -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
Expand All @@ -1105,13 +1131,24 @@ class SelectionOverlay {
/// Called when the users start dragging the end selection handles.
final ValueChanged<DragStartDetails>? 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<DragUpdateDetails>? onEndHandleDragUpdate;

/// Called when the users lift their fingers after dragging the end selection
/// handles.
final ValueChanged<DragEndDetails>? 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
Expand All @@ -1125,7 +1162,21 @@ class SelectionOverlay {
List<TextSelectionPoint> _selectionEndpoints;
set selectionEndpoints(List<TextSelectionPoint> 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;
}
Expand Down Expand Up @@ -1220,7 +1271,7 @@ class SelectionOverlay {
return;
}
_toolbarLocation = value;
_markNeedsBuild();
markNeedsBuild();
}

/// Controls the fade-in and fade-out animations for the toolbar and handles.
Expand Down Expand Up @@ -1250,7 +1301,6 @@ class SelectionOverlay {
OverlayEntry(builder: _buildStartHandle),
OverlayEntry(builder: _buildEndHandle),
];

Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insertAll(_handles!);
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
58 changes: 56 additions & 2 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,61 @@ void main() {
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ 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<TextSelectionPoint> 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();

Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions packages/flutter/test/rendering/editable_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading

0 comments on commit c2b2950

Please sign in to comment.