Unverified Commit 43770838 authored by jslavitz's avatar jslavitz Committed by GitHub

Selects a word on force tap (#25683)

* adds force press select word functionality
parent 35a7fd12
......@@ -451,6 +451,18 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
_renderEditable.handleTapDown(details);
}
void _handleForcePressStarted(ForcePressDetails details) {
// The cause is not keyboard press but we would still like to just
// highlight the word without showing any handles or toolbar.
_renderEditable.selectWordsInRange(from: details.globalPosition, cause: SelectionChangedCause.keyboard);
}
void _handleForcePressEnded(ForcePressDetails details) {
// The cause is not technically double tap, but we would still like to show
// the toolbar and handles.
_renderEditable.selectWordsInRange(from: details.globalPosition, cause: SelectionChangedCause.doubleTap);
}
void _handleSingleTapUp(TapUpDetails details) {
_renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
_requestKeyboard();
......@@ -648,6 +660,8 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
: CupertinoColors.darkBackgroundGray,
child: TextSelectionGestureDetector(
onTapDown: _handleTapDown,
onForcePressStart: _handleForcePressStarted,
onForcePressEnd: _handleForcePressEnded,
onSingleTapUp: _handleSingleTapUp,
onSingleLongTapDown: _handleSingleLongTapDown,
onDoubleTapDown: _handleDoubleTapDown,
......
......@@ -543,6 +543,21 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
_startSplash(details);
}
void _handleForcePressStarted(ForcePressDetails details) {
if (widget.selectionEnabled) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
// The cause is not technically double tap, but we would like to show
// the toolbar.
_renderEditable.selectWordsInRange(from: details.globalPosition, cause: SelectionChangedCause.doubleTap);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
break;
}
}
}
void _handleSingleTapUp(TapUpDetails details) {
if (widget.selectionEnabled) {
switch (Theme.of(context).platform) {
......@@ -706,6 +721,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
ignoring: !(widget.enabled ?? widget.decoration?.enabled ?? true),
child: TextSelectionGestureDetector(
onTapDown: _handleTapDown,
onForcePressStart: _handleForcePressStarted,
onSingleTapUp: _handleSingleTapUp,
onSingleTapCancel: _handleSingleTapCancel,
onSingleLongTapDown: _handleSingleLongTapDown,
......
......@@ -1184,12 +1184,29 @@ class RenderEditable extends RenderBox {
/// Select a word around the location of the last tap down.
void selectWord({@required SelectionChangedCause cause}) {
selectWordsInRange(from: _lastTapDownPosition, cause: cause);
}
/// Selects the set words of a paragraph in a given range of global positions.
///
/// The first and last endpoints of the selection will always be at the
/// beginning and end of a word respectively.
void selectWordsInRange({@required Offset from, Offset to, @required SelectionChangedCause cause}) {
assert(cause != null);
_layoutText(constraints.maxWidth);
assert(_lastTapDownPosition != null);
if (onSelectionChanged != null) {
final TextPosition position = _textPainter.getPositionForOffset(globalToLocal(_lastTapDownPosition));
onSelectionChanged(_selectWordAtOffset(position), this, cause);
final TextPosition firstPosition = _textPainter.getPositionForOffset(globalToLocal(from + -_paintOffset));
final TextSelection firstWord = _selectWordAtOffset(firstPosition);
final TextSelection lastWord = to == null ?
firstWord : _selectWordAtOffset(_textPainter.getPositionForOffset(globalToLocal(to + -_paintOffset)));
onSelectionChanged(
TextSelection(
baseOffset: firstWord.base.offset,
extentOffset: lastWord.extent.offset,
affinity: firstWord.affinity,
), this, cause,
);
}
}
......
......@@ -36,6 +36,7 @@ export 'package:flutter/gestures.dart' show
ScaleEndDetails,
TapDownDetails,
TapUpDetails,
ForcePressDetails,
Velocity;
// Examples can assume:
......
......@@ -612,6 +612,8 @@ class TextSelectionGestureDetector extends StatefulWidget {
const TextSelectionGestureDetector({
Key key,
this.onTapDown,
this.onForcePressStart,
this.onForcePressEnd,
this.onSingleTapUp,
this.onSingleTapCancel,
this.onSingleLongTapDown,
......@@ -626,6 +628,14 @@ class TextSelectionGestureDetector extends StatefulWidget {
/// to not qualify as taps (e.g. pans and flings).
final GestureTapDownCallback onTapDown;
/// Called when a pointer has tapped down and the force of the pointer has
/// just become greater than [ForcePressGestureDetector.startPressure].
final GestureForcePressStartCallback onForcePressStart;
/// Called when a pointer that had previously triggered [onForcePressStart] is
/// lifted off the screen.
final GestureForcePressEndCallback onForcePressEnd;
/// Called for each distinct tap except for every second tap of a double tap.
/// For example, if the detector was configured [onSingleTapDown] and
/// [onDoubleTapDown], three quick taps would be recognized as a single tap
......@@ -712,6 +722,18 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
}
}
void _forcePressStarted(ForcePressDetails details) {
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onForcePressStart != null)
widget.onForcePressStart(details);
}
void _forcePressEnded(ForcePressDetails details) {
if (widget.onForcePressEnd != null)
widget.onForcePressEnd(details);
}
void _handleLongPress() {
if (!_isDoubleTap && widget.onSingleLongTapDown != null) {
widget.onSingleLongTapDown();
......@@ -739,6 +761,8 @@ class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetec
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onForcePressStart: _forcePressStarted,
onForcePressEnd: _forcePressEnded,
onTapCancel: _handleTapCancel,
onLongPress: _handleLongPress,
excludeFromSemantics: true,
......
......@@ -1099,6 +1099,38 @@ void main() {
},
);
testWidgets('force press selects word', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
controller: controller,
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
const int pointerValue = 1;
final TestGesture gesture =
await tester.startGesture(textfieldStart + const Offset(150.0, 5.0));
await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: textfieldStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
// We expect the force press to select a word at the given location.
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(CupertinoButton), findsNWidgets(3));
},
);
testWidgets(
'text field respects theme',
(WidgetTester tester) async {
......
......@@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior;
import '../widgets/semantics_tester.dart';
......@@ -4204,6 +4205,70 @@ void main() {
},
);
testWidgets('force press does not select a word on (android)', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
const int pointerValue = 1;
final TestGesture gesture =
await tester.startGesture(textfieldStart + const Offset(150.0, 5.0));
await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: textfieldStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
// We don't want this gesture to select any word on Android.
expect(controller.selection, const TextSelection.collapsed(offset: -1));
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(FlatButton), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('force press selects word (iOS)', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(
controller: controller,
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
const int pointerValue = 1;
final TestGesture gesture =
await tester.startGesture(textfieldStart + const Offset(150.0, 5.0));
await gesture.updateWithCustomEvent(PointerMoveEvent(pointer: pointerValue, position: textfieldStart + const Offset(150.0, 5.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
// We expect the force press to select a word at the given location.
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(CupertinoButton), findsNWidgets(3));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('default TextField debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
......
......@@ -11,12 +11,16 @@ void main() {
int singleTapCancelCount;
int singleLongTapDownCount;
int doubleTapDownCount;
int forcePressStartCount;
int forcePressEndCount;
void _handleTapDown(TapDownDetails details) { tapCount++; }
void _handleSingleTapUp(TapUpDetails details) { singleTapUpCount++; }
void _handleSingleTapCancel() { singleTapCancelCount++; }
void _handleSingleLongTapDown() { singleLongTapDownCount++; }
void _handleDoubleTapDown(TapDownDetails details) { doubleTapDownCount++; }
void _handleForcePressStart(ForcePressDetails details) { forcePressStartCount++; }
void _handleForcePressEnd(ForcePressDetails details) { forcePressEndCount++; }
setUp(() {
tapCount = 0;
......@@ -24,6 +28,8 @@ void main() {
singleTapCancelCount = 0;
singleLongTapDownCount = 0;
doubleTapDownCount = 0;
forcePressStartCount = 0;
forcePressEndCount = 0;
});
Future<void> pumpGestureDetector(WidgetTester tester) async {
......@@ -35,6 +41,8 @@ void main() {
onSingleTapCancel: _handleSingleTapCancel,
onSingleLongTapDown: _handleSingleLongTapDown,
onDoubleTapDown: _handleDoubleTapDown,
onForcePressStart: _handleForcePressStart,
onForcePressEnd: _handleForcePressEnd,
child: Container(),
),
);
......@@ -142,4 +150,54 @@ void main() {
expect(doubleTapDownCount, 0);
expect(singleLongTapDownCount, 0);
});
testWidgets('a force press intiates a force press', (WidgetTester tester) async {
await pumpGestureDetector(tester);
const int pointerValue = 1;
TestGesture gesture = await tester.startGesture(const Offset(400.0, 50.0), pointer: pointerValue);
await gesture.updateWithCustomEvent(const PointerMoveEvent(pointer: pointerValue, position: Offset(0.0, 0.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
await gesture.up();
await tester.pumpAndSettle();
gesture = await tester.startGesture(const Offset(400.0, 50.0), pointer: pointerValue);
await gesture.updateWithCustomEvent(const PointerMoveEvent(pointer: pointerValue, position: Offset(0.0, 0.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
await gesture.up();
await tester.pump(const Duration(milliseconds: 20));
gesture = await tester.startGesture(const Offset(400.0, 50.0), pointer: pointerValue);
await gesture.updateWithCustomEvent(const PointerMoveEvent(pointer: pointerValue, position: Offset(0.0, 0.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
await gesture.up();
await tester.pump(const Duration(milliseconds: 20));
gesture = await tester.startGesture(const Offset(400.0, 50.0), pointer: pointerValue);
await gesture.updateWithCustomEvent(const PointerMoveEvent(pointer: pointerValue, position: Offset(0.0, 0.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
await gesture.up();
expect(forcePressStartCount, 4);
});
testWidgets('a tap and then force press intiates a force press and not a double tap', (WidgetTester tester) async {
await pumpGestureDetector(tester);
const int pointerValue = 1;
TestGesture gesture = await tester.startGesture(const Offset(400.0, 50.0), pointer: pointerValue);
// Initiate a quick tap.
await gesture.updateWithCustomEvent(const PointerMoveEvent(pointer: pointerValue, position: Offset(0.0, 0.0), pressure: 0.0, pressureMin: 0, pressureMax: 1));
await tester.pump(const Duration(milliseconds: 50));
await gesture.up();
// Initiate a force tap.
gesture = await tester.startGesture(const Offset(400.0, 50.0), pointer: pointerValue);
await gesture.updateWithCustomEvent(const PointerMoveEvent(pointer: pointerValue, position: Offset(0.0, 0.0), pressure: 0.5, pressureMin: 0, pressureMax: 1));
expect(forcePressStartCount, 1);
await tester.pump(const Duration(milliseconds: 50));
await gesture.up();
await tester.pumpAndSettle();
expect(forcePressEndCount, 1);
expect(doubleTapDownCount, 0);
});
}
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