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.
This commit is contained in:
parent
7de612add0
commit
6fd7987b4b
@ -37,11 +37,12 @@ class _TextSelectionToolbar extends StatelessWidget {
|
||||
// TODO(mpcomplete): This should probably be grayed-out if there is nothing to paste.
|
||||
onPressed: _handlePaste
|
||||
));
|
||||
if (value.selection.isCollapsed) {
|
||||
items.add(new FlatButton(child: new Text('SELECT ALL'), onPressed: _handleSelectAll));
|
||||
if (value.text.isNotEmpty) {
|
||||
if (value.selection.isCollapsed)
|
||||
items.add(new FlatButton(child: new Text('SELECT ALL'), onPressed: _handleSelectAll));
|
||||
// TODO(mpcomplete): implement `more` menu.
|
||||
items.add(new IconButton(icon: Icons.more_vert));
|
||||
}
|
||||
// TODO(mpcomplete): implement `more` menu.
|
||||
items.add(new IconButton(icon: Icons.more_vert));
|
||||
|
||||
return new Material(
|
||||
elevation: 1,
|
||||
|
@ -17,7 +17,7 @@ const double _kCaretWidth = 1.0; // pixels
|
||||
final String _kZeroWidthSpace = new String.fromCharCode(0x200B);
|
||||
|
||||
/// 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
|
||||
/// text direction at that point.
|
||||
@ -128,7 +128,7 @@ class RenderEditableLine extends RenderBox {
|
||||
if (selection.isCollapsed) {
|
||||
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
|
||||
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)];
|
||||
} else {
|
||||
List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection);
|
||||
@ -212,7 +212,7 @@ class RenderEditableLine extends RenderBox {
|
||||
_lastTapDownPosition = null;
|
||||
if (onSelectionChanged != null) {
|
||||
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 {
|
||||
_longPressPosition = null;
|
||||
if (onSelectionChanged != null) {
|
||||
TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset());
|
||||
onSelectionChanged(_selectWordAtOffset(position), this);
|
||||
onSelectionChanged(_selectWordAtOffset(position), this, true);
|
||||
}
|
||||
}
|
||||
|
||||
TextSelection _selectWordAtOffset(TextPosition 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);
|
||||
}
|
||||
|
||||
|
@ -299,7 +299,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
|
||||
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
|
||||
// EditableLineWidget, not just changes triggered by user gestures.
|
||||
requestKeyboard();
|
||||
@ -313,7 +313,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
|
||||
_selectionOverlay = null;
|
||||
}
|
||||
|
||||
if (newInput.text.isNotEmpty && config.selectionHandleBuilder != null) {
|
||||
if (config.selectionHandleBuilder != null) {
|
||||
_selectionOverlay = new TextSelectionOverlay(
|
||||
input: newInput,
|
||||
context: context,
|
||||
@ -323,7 +323,10 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
|
||||
handleBuilder: config.selectionHandleBuilder,
|
||||
toolbarBuilder: config.selectionToolbarBuilder
|
||||
);
|
||||
_selectionOverlay.show();
|
||||
if (newInput.text.isNotEmpty || longPress)
|
||||
_selectionOverlay.showHandles();
|
||||
if (longPress)
|
||||
_selectionOverlay.showToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,40 +73,49 @@ class TextSelectionOverlay implements TextSelectionDelegate {
|
||||
/// second is hidden when the selection is collapsed.
|
||||
List<OverlayEntry> _handles;
|
||||
|
||||
/// A copy/paste toolbar.
|
||||
OverlayEntry _toolbar;
|
||||
|
||||
TextSelection get _selection => _input.selection;
|
||||
|
||||
/// Shows the handles by inserting them into the [context]'s overlay.
|
||||
void show() {
|
||||
void showHandles() {
|
||||
assert(_handles == null);
|
||||
_handles = <OverlayEntry>[
|
||||
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.start)),
|
||||
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.end)),
|
||||
];
|
||||
_toolbar = new OverlayEntry(builder: _buildToolbar);
|
||||
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);
|
||||
}
|
||||
|
||||
/// Updates the handles after the [selection] has changed.
|
||||
/// Updates the overlay after the [selection] has changed.
|
||||
void update(InputValue newInput) {
|
||||
_input = newInput;
|
||||
if (_handles == null)
|
||||
if (_input == newInput)
|
||||
return;
|
||||
_handles[0].markNeedsBuild();
|
||||
_handles[1].markNeedsBuild();
|
||||
_toolbar.markNeedsBuild();
|
||||
|
||||
_input = newInput;
|
||||
if (_handles != null) {
|
||||
_handles[0].markNeedsBuild();
|
||||
_handles[1].markNeedsBuild();
|
||||
}
|
||||
_toolbar?.markNeedsBuild();
|
||||
}
|
||||
|
||||
/// Hides the handles.
|
||||
/// Hides the overlay.
|
||||
void hide() {
|
||||
if (_handles == null)
|
||||
return;
|
||||
_handles[0].remove();
|
||||
_handles[1].remove();
|
||||
_handles = null;
|
||||
_toolbar.remove();
|
||||
if (_handles != null) {
|
||||
_handles[0].remove();
|
||||
_handles[1].remove();
|
||||
_handles = null;
|
||||
}
|
||||
_toolbar?.remove();
|
||||
_toolbar = null;
|
||||
}
|
||||
|
||||
@ -116,6 +125,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
|
||||
return new Container(); // hide the second handle when collapsed
|
||||
return new _TextSelectionHandleOverlay(
|
||||
onSelectionHandleChanged: _handleSelectionHandleChanged,
|
||||
onSelectionHandleTapped: _handleSelectionHandleTapped,
|
||||
renderObject: renderObject,
|
||||
selection: _selection,
|
||||
builder: handleBuilder,
|
||||
@ -143,6 +153,17 @@ class TextSelectionOverlay implements TextSelectionDelegate {
|
||||
inputValue = _input.copyWith(selection: newSelection, composing: TextRange.empty);
|
||||
}
|
||||
|
||||
void _handleSelectionHandleTapped() {
|
||||
if (inputValue.selection.isCollapsed) {
|
||||
if (_toolbar != null) {
|
||||
_toolbar?.remove();
|
||||
_toolbar = null;
|
||||
} else {
|
||||
showToolbar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
InputValue get inputValue => _input;
|
||||
|
||||
@ -167,6 +188,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget {
|
||||
this.position,
|
||||
this.renderObject,
|
||||
this.onSelectionHandleChanged,
|
||||
this.onSelectionHandleTapped,
|
||||
this.builder
|
||||
}) : super(key: key);
|
||||
|
||||
@ -174,6 +196,7 @@ class _TextSelectionHandleOverlay extends StatefulWidget {
|
||||
final _TextSelectionHandlePosition position;
|
||||
final RenderEditableLine renderObject;
|
||||
final ValueChanged<TextSelection> onSelectionHandleChanged;
|
||||
final VoidCallback onSelectionHandleTapped;
|
||||
final TextSelectionHandleBuilder builder;
|
||||
|
||||
@override
|
||||
@ -217,6 +240,10 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
|
||||
config.onSelectionHandleChanged(newSelection);
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
config.onSelectionHandleTapped();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<TextSelectionPoint> endpoints = config.renderObject.getEndpointsForSelection(config.selection);
|
||||
@ -240,6 +267,7 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
|
||||
return new GestureDetector(
|
||||
onHorizontalDragStart: _handleDragStart,
|
||||
onHorizontalDragUpdate: _handleDragUpdate,
|
||||
onTap: _handleTap,
|
||||
child: new Stack(
|
||||
children: <Widget>[
|
||||
new Positioned(
|
||||
|
@ -342,9 +342,14 @@ void main() {
|
||||
enterText(testValue);
|
||||
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.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.
|
||||
tester.tap(find.text('SELECT ALL'));
|
||||
@ -360,6 +365,10 @@ void main() {
|
||||
// Tap again to bring back the menu.
|
||||
tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
|
||||
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'.
|
||||
tester.tap(find.text('PASTE'));
|
||||
|
Loading…
x
Reference in New Issue
Block a user