Unverified Commit c2b29501 authored by chunhtai's avatar chunhtai Committed by GitHub

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
parent a1ea383f
......@@ -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
......
......@@ -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();
......@@ -537,6 +538,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
},
);
}
_stopSelectionStartEdgeUpdate();
_stopSelectionEndEdgeUpdate();
_updateSelectedContentIfNeeded();
}
......
......@@ -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<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;
......@@ -1066,6 +1085,11 @@ class SelectionOverlay {
/// 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.
......@@ -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<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;
......@@ -1112,6 +1144,11 @@ class SelectionOverlay {
/// 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
......@@ -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;
}
......@@ -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,
......
......@@ -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();
......@@ -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 {
......
......@@ -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();
......
......@@ -463,6 +463,7 @@ void main() {
final List<TextBox> 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<RenderParagraph>(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<TextBox> 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<RenderParagraph>(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<TextBox> 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();
......
......@@ -298,6 +298,67 @@ void main() {
});
});
testWidgets('dragging handle or selecting word triggers haptic feedback on Android', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[];
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<RenderParagraph>(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<TextBox> 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(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment