Commit 6fd7987b authored by Matt Perry's avatar Matt Perry

Text selection UI matches behavior on Android. (#3886)

- Handles appear with tap or long press.
- Toolbar appears with long press on text, or tap on handle.
- Correct toolbar items shown depending on context.
parent 7de612ad
...@@ -37,11 +37,12 @@ class _TextSelectionToolbar extends StatelessWidget { ...@@ -37,11 +37,12 @@ class _TextSelectionToolbar extends StatelessWidget {
// TODO(mpcomplete): This should probably be grayed-out if there is nothing to paste. // TODO(mpcomplete): This should probably be grayed-out if there is nothing to paste.
onPressed: _handlePaste onPressed: _handlePaste
)); ));
if (value.selection.isCollapsed) { if (value.text.isNotEmpty) {
if (value.selection.isCollapsed)
items.add(new FlatButton(child: new Text('SELECT ALL'), onPressed: _handleSelectAll)); items.add(new FlatButton(child: new Text('SELECT ALL'), onPressed: _handleSelectAll));
}
// TODO(mpcomplete): implement `more` menu. // TODO(mpcomplete): implement `more` menu.
items.add(new IconButton(icon: Icons.more_vert)); items.add(new IconButton(icon: Icons.more_vert));
}
return new Material( return new Material(
elevation: 1, elevation: 1,
......
...@@ -17,7 +17,7 @@ const double _kCaretWidth = 1.0; // pixels ...@@ -17,7 +17,7 @@ const double _kCaretWidth = 1.0; // pixels
final String _kZeroWidthSpace = new String.fromCharCode(0x200B); final String _kZeroWidthSpace = new String.fromCharCode(0x200B);
/// Called when the user changes the selection (including cursor location). /// Called when the user changes the selection (including cursor location).
typedef void SelectionChangedHandler(TextSelection selection, RenderEditableLine renderObject); typedef void SelectionChangedHandler(TextSelection selection, RenderEditableLine renderObject, bool longPress);
/// Represents a global screen coordinate of the point in a selection, and the /// Represents a global screen coordinate of the point in a selection, and the
/// text direction at that point. /// text direction at that point.
...@@ -128,7 +128,7 @@ class RenderEditableLine extends RenderBox { ...@@ -128,7 +128,7 @@ class RenderEditableLine extends RenderBox {
if (selection.isCollapsed) { if (selection.isCollapsed) {
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary. // TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype); Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype);
Point start = new Point(caretOffset.dx, _contentSize.height) + offset; Point start = new Point(caretOffset.dx, size.height) + offset;
return <TextSelectionPoint>[new TextSelectionPoint(localToGlobal(start), null)]; return <TextSelectionPoint>[new TextSelectionPoint(localToGlobal(start), null)];
} else { } else {
List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection); List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection);
...@@ -212,7 +212,7 @@ class RenderEditableLine extends RenderBox { ...@@ -212,7 +212,7 @@ class RenderEditableLine extends RenderBox {
_lastTapDownPosition = null; _lastTapDownPosition = null;
if (onSelectionChanged != null) { if (onSelectionChanged != null) {
TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset()); TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset());
onSelectionChanged(new TextSelection.fromPosition(position), this); onSelectionChanged(new TextSelection.fromPosition(position), this, false);
} }
} }
...@@ -227,12 +227,15 @@ class RenderEditableLine extends RenderBox { ...@@ -227,12 +227,15 @@ class RenderEditableLine extends RenderBox {
_longPressPosition = null; _longPressPosition = null;
if (onSelectionChanged != null) { if (onSelectionChanged != null) {
TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset()); TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset());
onSelectionChanged(_selectWordAtOffset(position), this); onSelectionChanged(_selectWordAtOffset(position), this, true);
} }
} }
TextSelection _selectWordAtOffset(TextPosition position) { TextSelection _selectWordAtOffset(TextPosition position) {
TextRange word = _textPainter.getWordBoundary(position); TextRange word = _textPainter.getWordBoundary(position);
// When long-pressing past the end of the text, we want a collapsed cursor.
if (position.offset >= word.end)
return new TextSelection.fromPosition(position);
return new TextSelection(baseOffset: word.start, extentOffset: word.end); return new TextSelection(baseOffset: word.start, extentOffset: word.end);
} }
......
...@@ -299,7 +299,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> { ...@@ -299,7 +299,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
config.onSubmitted(_keyboardClient.inputValue); config.onSubmitted(_keyboardClient.inputValue);
} }
void _handleSelectionChanged(TextSelection selection, RenderEditableLine renderObject) { void _handleSelectionChanged(TextSelection selection, RenderEditableLine renderObject, bool longPress) {
// Note that this will show the keyboard for all selection changes on the // Note that this will show the keyboard for all selection changes on the
// EditableLineWidget, not just changes triggered by user gestures. // EditableLineWidget, not just changes triggered by user gestures.
requestKeyboard(); requestKeyboard();
...@@ -313,7 +313,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> { ...@@ -313,7 +313,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
_selectionOverlay = null; _selectionOverlay = null;
} }
if (newInput.text.isNotEmpty && config.selectionHandleBuilder != null) { if (config.selectionHandleBuilder != null) {
_selectionOverlay = new TextSelectionOverlay( _selectionOverlay = new TextSelectionOverlay(
input: newInput, input: newInput,
context: context, context: context,
...@@ -323,7 +323,10 @@ class RawInputLineState extends ScrollableState<RawInputLine> { ...@@ -323,7 +323,10 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
handleBuilder: config.selectionHandleBuilder, handleBuilder: config.selectionHandleBuilder,
toolbarBuilder: config.selectionToolbarBuilder toolbarBuilder: config.selectionToolbarBuilder
); );
_selectionOverlay.show(); if (newInput.text.isNotEmpty || longPress)
_selectionOverlay.showHandles();
if (longPress)
_selectionOverlay.showToolbar();
} }
} }
......
...@@ -73,40 +73,49 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -73,40 +73,49 @@ class TextSelectionOverlay implements TextSelectionDelegate {
/// second is hidden when the selection is collapsed. /// second is hidden when the selection is collapsed.
List<OverlayEntry> _handles; List<OverlayEntry> _handles;
/// A copy/paste toolbar.
OverlayEntry _toolbar; OverlayEntry _toolbar;
TextSelection get _selection => _input.selection; TextSelection get _selection => _input.selection;
/// Shows the handles by inserting them into the [context]'s overlay. /// Shows the handles by inserting them into the [context]'s overlay.
void show() { void showHandles() {
assert(_handles == null); assert(_handles == null);
_handles = <OverlayEntry>[ _handles = <OverlayEntry>[
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.start)), new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.start)),
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.end)), new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.end)),
]; ];
_toolbar = new OverlayEntry(builder: _buildToolbar);
Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles); Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
}
/// Shows the toolbar by inserting it into the [context]'s overlay.
void showToolbar() {
assert(_toolbar == null);
_toolbar = new OverlayEntry(builder: _buildToolbar);
Overlay.of(context, debugRequiredFor: debugRequiredFor).insert(_toolbar); Overlay.of(context, debugRequiredFor: debugRequiredFor).insert(_toolbar);
} }
/// Updates the handles after the [selection] has changed. /// Updates the overlay after the [selection] has changed.
void update(InputValue newInput) { void update(InputValue newInput) {
_input = newInput; if (_input == newInput)
if (_handles == null)
return; return;
_input = newInput;
if (_handles != null) {
_handles[0].markNeedsBuild(); _handles[0].markNeedsBuild();
_handles[1].markNeedsBuild(); _handles[1].markNeedsBuild();
_toolbar.markNeedsBuild(); }
_toolbar?.markNeedsBuild();
} }
/// Hides the handles. /// Hides the overlay.
void hide() { void hide() {
if (_handles == null) if (_handles != null) {
return;
_handles[0].remove(); _handles[0].remove();
_handles[1].remove(); _handles[1].remove();
_handles = null; _handles = null;
_toolbar.remove(); }
_toolbar?.remove();
_toolbar = null; _toolbar = null;
} }
...@@ -116,6 +125,7 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -116,6 +125,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
return new Container(); // hide the second handle when collapsed return new Container(); // hide the second handle when collapsed
return new _TextSelectionHandleOverlay( return new _TextSelectionHandleOverlay(
onSelectionHandleChanged: _handleSelectionHandleChanged, onSelectionHandleChanged: _handleSelectionHandleChanged,
onSelectionHandleTapped: _handleSelectionHandleTapped,
renderObject: renderObject, renderObject: renderObject,
selection: _selection, selection: _selection,
builder: handleBuilder, builder: handleBuilder,
...@@ -143,6 +153,17 @@ class TextSelectionOverlay implements TextSelectionDelegate { ...@@ -143,6 +153,17 @@ class TextSelectionOverlay implements TextSelectionDelegate {
inputValue = _input.copyWith(selection: newSelection, composing: TextRange.empty); inputValue = _input.copyWith(selection: newSelection, composing: TextRange.empty);
} }
void _handleSelectionHandleTapped() {
if (inputValue.selection.isCollapsed) {
if (_toolbar != null) {
_toolbar?.remove();
_toolbar = null;
} else {
showToolbar();
}
}
}
@override @override
InputValue get inputValue => _input; InputValue get inputValue => _input;
...@@ -167,6 +188,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { ...@@ -167,6 +188,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget {
this.position, this.position,
this.renderObject, this.renderObject,
this.onSelectionHandleChanged, this.onSelectionHandleChanged,
this.onSelectionHandleTapped,
this.builder this.builder
}) : super(key: key); }) : super(key: key);
...@@ -174,6 +196,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget { ...@@ -174,6 +196,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget {
final _TextSelectionHandlePosition position; final _TextSelectionHandlePosition position;
final RenderEditableLine renderObject; final RenderEditableLine renderObject;
final ValueChanged<TextSelection> onSelectionHandleChanged; final ValueChanged<TextSelection> onSelectionHandleChanged;
final VoidCallback onSelectionHandleTapped;
final TextSelectionHandleBuilder builder; final TextSelectionHandleBuilder builder;
@override @override
...@@ -217,6 +240,10 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay ...@@ -217,6 +240,10 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
config.onSelectionHandleChanged(newSelection); config.onSelectionHandleChanged(newSelection);
} }
void _handleTap() {
config.onSelectionHandleTapped();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
List<TextSelectionPoint> endpoints = config.renderObject.getEndpointsForSelection(config.selection); List<TextSelectionPoint> endpoints = config.renderObject.getEndpointsForSelection(config.selection);
...@@ -240,6 +267,7 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay ...@@ -240,6 +267,7 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
return new GestureDetector( return new GestureDetector(
onHorizontalDragStart: _handleDragStart, onHorizontalDragStart: _handleDragStart,
onHorizontalDragUpdate: _handleDragUpdate, onHorizontalDragUpdate: _handleDragUpdate,
onTap: _handleTap,
child: new Stack( child: new Stack(
children: <Widget>[ children: <Widget>[
new Positioned( new Positioned(
......
...@@ -342,9 +342,14 @@ void main() { ...@@ -342,9 +342,14 @@ void main() {
enterText(testValue); enterText(testValue);
tester.pumpWidget(builder()); tester.pumpWidget(builder());
// Tap the text to bring up the "paste / select all" menu. // Tap the selection handle to bring up the "paste / select all" menu.
tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
tester.pumpWidget(builder()); tester.pumpWidget(builder());
RenderEditableLine renderLine = findRenderEditableLine(tester);
List<TextSelectionPoint> endpoints = renderLine.getEndpointsForSelection(
inputValue.selection);
tester.tapAt(endpoints[0].point + new Offset(1.0, 1.0));
tester.pumpWidget(builder());
// SELECT ALL should select all the text. // SELECT ALL should select all the text.
tester.tap(find.text('SELECT ALL')); tester.tap(find.text('SELECT ALL'));
...@@ -360,6 +365,10 @@ void main() { ...@@ -360,6 +365,10 @@ void main() {
// Tap again to bring back the menu. // Tap again to bring back the menu.
tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
tester.pumpWidget(builder()); tester.pumpWidget(builder());
renderLine = findRenderEditableLine(tester);
endpoints = renderLine.getEndpointsForSelection(inputValue.selection);
tester.tapAt(endpoints[0].point + new Offset(1.0, 1.0));
tester.pumpWidget(builder());
// PASTE right before the 'e'. // PASTE right before the 'e'.
tester.tap(find.text('PASTE')); tester.tap(find.text('PASTE'));
......
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