Skip to content

Commit 6fd7987

Browse files
committed
Text selection UI matches behavior on Android. (flutter#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.
1 parent 7de612a commit 6fd7987

File tree

5 files changed

+71
-27
lines changed

5 files changed

+71
-27
lines changed

packages/flutter/lib/src/material/text_selection.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@ class _TextSelectionToolbar extends StatelessWidget {
3737
// TODO(mpcomplete): This should probably be grayed-out if there is nothing to paste.
3838
onPressed: _handlePaste
3939
));
40-
if (value.selection.isCollapsed) {
41-
items.add(new FlatButton(child: new Text('SELECT ALL'), onPressed: _handleSelectAll));
40+
if (value.text.isNotEmpty) {
41+
if (value.selection.isCollapsed)
42+
items.add(new FlatButton(child: new Text('SELECT ALL'), onPressed: _handleSelectAll));
43+
// TODO(mpcomplete): implement `more` menu.
44+
items.add(new IconButton(icon: Icons.more_vert));
4245
}
43-
// TODO(mpcomplete): implement `more` menu.
44-
items.add(new IconButton(icon: Icons.more_vert));
4546

4647
return new Material(
4748
elevation: 1,

packages/flutter/lib/src/rendering/editable_line.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const double _kCaretWidth = 1.0; // pixels
1717
final String _kZeroWidthSpace = new String.fromCharCode(0x200B);
1818

1919
/// Called when the user changes the selection (including cursor location).
20-
typedef void SelectionChangedHandler(TextSelection selection, RenderEditableLine renderObject);
20+
typedef void SelectionChangedHandler(TextSelection selection, RenderEditableLine renderObject, bool longPress);
2121

2222
/// Represents a global screen coordinate of the point in a selection, and the
2323
/// text direction at that point.
@@ -128,7 +128,7 @@ class RenderEditableLine extends RenderBox {
128128
if (selection.isCollapsed) {
129129
// TODO(mpcomplete): This doesn't work well at an RTL/LTR boundary.
130130
Offset caretOffset = _textPainter.getOffsetForCaret(selection.extent, _caretPrototype);
131-
Point start = new Point(caretOffset.dx, _contentSize.height) + offset;
131+
Point start = new Point(caretOffset.dx, size.height) + offset;
132132
return <TextSelectionPoint>[new TextSelectionPoint(localToGlobal(start), null)];
133133
} else {
134134
List<ui.TextBox> boxes = _textPainter.getBoxesForSelection(selection);
@@ -212,7 +212,7 @@ class RenderEditableLine extends RenderBox {
212212
_lastTapDownPosition = null;
213213
if (onSelectionChanged != null) {
214214
TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset());
215-
onSelectionChanged(new TextSelection.fromPosition(position), this);
215+
onSelectionChanged(new TextSelection.fromPosition(position), this, false);
216216
}
217217
}
218218

@@ -227,12 +227,15 @@ class RenderEditableLine extends RenderBox {
227227
_longPressPosition = null;
228228
if (onSelectionChanged != null) {
229229
TextPosition position = _textPainter.getPositionForOffset(globalToLocal(global).toOffset());
230-
onSelectionChanged(_selectWordAtOffset(position), this);
230+
onSelectionChanged(_selectWordAtOffset(position), this, true);
231231
}
232232
}
233233

234234
TextSelection _selectWordAtOffset(TextPosition position) {
235235
TextRange word = _textPainter.getWordBoundary(position);
236+
// When long-pressing past the end of the text, we want a collapsed cursor.
237+
if (position.offset >= word.end)
238+
return new TextSelection.fromPosition(position);
236239
return new TextSelection(baseOffset: word.start, extentOffset: word.end);
237240
}
238241

packages/flutter/lib/src/widgets/editable.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
299299
config.onSubmitted(_keyboardClient.inputValue);
300300
}
301301

302-
void _handleSelectionChanged(TextSelection selection, RenderEditableLine renderObject) {
302+
void _handleSelectionChanged(TextSelection selection, RenderEditableLine renderObject, bool longPress) {
303303
// Note that this will show the keyboard for all selection changes on the
304304
// EditableLineWidget, not just changes triggered by user gestures.
305305
requestKeyboard();
@@ -313,7 +313,7 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
313313
_selectionOverlay = null;
314314
}
315315

316-
if (newInput.text.isNotEmpty && config.selectionHandleBuilder != null) {
316+
if (config.selectionHandleBuilder != null) {
317317
_selectionOverlay = new TextSelectionOverlay(
318318
input: newInput,
319319
context: context,
@@ -323,7 +323,10 @@ class RawInputLineState extends ScrollableState<RawInputLine> {
323323
handleBuilder: config.selectionHandleBuilder,
324324
toolbarBuilder: config.selectionToolbarBuilder
325325
);
326-
_selectionOverlay.show();
326+
if (newInput.text.isNotEmpty || longPress)
327+
_selectionOverlay.showHandles();
328+
if (longPress)
329+
_selectionOverlay.showToolbar();
327330
}
328331
}
329332

packages/flutter/lib/src/widgets/text_selection.dart

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,40 +73,49 @@ class TextSelectionOverlay implements TextSelectionDelegate {
7373
/// second is hidden when the selection is collapsed.
7474
List<OverlayEntry> _handles;
7575

76+
/// A copy/paste toolbar.
7677
OverlayEntry _toolbar;
7778

7879
TextSelection get _selection => _input.selection;
7980

8081
/// Shows the handles by inserting them into the [context]'s overlay.
81-
void show() {
82+
void showHandles() {
8283
assert(_handles == null);
8384
_handles = <OverlayEntry>[
8485
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.start)),
8586
new OverlayEntry(builder: (BuildContext c) => _buildOverlay(c, _TextSelectionHandlePosition.end)),
8687
];
87-
_toolbar = new OverlayEntry(builder: _buildToolbar);
8888
Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
89+
}
90+
91+
/// Shows the toolbar by inserting it into the [context]'s overlay.
92+
void showToolbar() {
93+
assert(_toolbar == null);
94+
_toolbar = new OverlayEntry(builder: _buildToolbar);
8995
Overlay.of(context, debugRequiredFor: debugRequiredFor).insert(_toolbar);
9096
}
9197

92-
/// Updates the handles after the [selection] has changed.
98+
/// Updates the overlay after the [selection] has changed.
9399
void update(InputValue newInput) {
94-
_input = newInput;
95-
if (_handles == null)
100+
if (_input == newInput)
96101
return;
97-
_handles[0].markNeedsBuild();
98-
_handles[1].markNeedsBuild();
99-
_toolbar.markNeedsBuild();
102+
103+
_input = newInput;
104+
if (_handles != null) {
105+
_handles[0].markNeedsBuild();
106+
_handles[1].markNeedsBuild();
107+
}
108+
_toolbar?.markNeedsBuild();
100109
}
101110

102-
/// Hides the handles.
111+
/// Hides the overlay.
103112
void hide() {
104-
if (_handles == null)
105-
return;
106-
_handles[0].remove();
107-
_handles[1].remove();
108-
_handles = null;
109-
_toolbar.remove();
113+
if (_handles != null) {
114+
_handles[0].remove();
115+
_handles[1].remove();
116+
_handles = null;
117+
}
118+
_toolbar?.remove();
110119
_toolbar = null;
111120
}
112121

@@ -116,6 +125,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
116125
return new Container(); // hide the second handle when collapsed
117126
return new _TextSelectionHandleOverlay(
118127
onSelectionHandleChanged: _handleSelectionHandleChanged,
128+
onSelectionHandleTapped: _handleSelectionHandleTapped,
119129
renderObject: renderObject,
120130
selection: _selection,
121131
builder: handleBuilder,
@@ -143,6 +153,17 @@ class TextSelectionOverlay implements TextSelectionDelegate {
143153
inputValue = _input.copyWith(selection: newSelection, composing: TextRange.empty);
144154
}
145155

156+
void _handleSelectionHandleTapped() {
157+
if (inputValue.selection.isCollapsed) {
158+
if (_toolbar != null) {
159+
_toolbar?.remove();
160+
_toolbar = null;
161+
} else {
162+
showToolbar();
163+
}
164+
}
165+
}
166+
146167
@override
147168
InputValue get inputValue => _input;
148169

@@ -167,13 +188,15 @@ class _TextSelectionHandleOverlay extends StatefulWidget {
167188
this.position,
168189
this.renderObject,
169190
this.onSelectionHandleChanged,
191+
this.onSelectionHandleTapped,
170192
this.builder
171193
}) : super(key: key);
172194

173195
final TextSelection selection;
174196
final _TextSelectionHandlePosition position;
175197
final RenderEditableLine renderObject;
176198
final ValueChanged<TextSelection> onSelectionHandleChanged;
199+
final VoidCallback onSelectionHandleTapped;
177200
final TextSelectionHandleBuilder builder;
178201

179202
@override
@@ -217,6 +240,10 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
217240
config.onSelectionHandleChanged(newSelection);
218241
}
219242

243+
void _handleTap() {
244+
config.onSelectionHandleTapped();
245+
}
246+
220247
@override
221248
Widget build(BuildContext context) {
222249
List<TextSelectionPoint> endpoints = config.renderObject.getEndpointsForSelection(config.selection);
@@ -240,6 +267,7 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay
240267
return new GestureDetector(
241268
onHorizontalDragStart: _handleDragStart,
242269
onHorizontalDragUpdate: _handleDragUpdate,
270+
onTap: _handleTap,
243271
child: new Stack(
244272
children: <Widget>[
245273
new Positioned(

packages/flutter/test/widget/input_test.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,14 @@ void main() {
342342
enterText(testValue);
343343
tester.pumpWidget(builder());
344344

345-
// Tap the text to bring up the "paste / select all" menu.
345+
// Tap the selection handle to bring up the "paste / select all" menu.
346346
tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
347347
tester.pumpWidget(builder());
348+
RenderEditableLine renderLine = findRenderEditableLine(tester);
349+
List<TextSelectionPoint> endpoints = renderLine.getEndpointsForSelection(
350+
inputValue.selection);
351+
tester.tapAt(endpoints[0].point + new Offset(1.0, 1.0));
352+
tester.pumpWidget(builder());
348353

349354
// SELECT ALL should select all the text.
350355
tester.tap(find.text('SELECT ALL'));
@@ -360,6 +365,10 @@ void main() {
360365
// Tap again to bring back the menu.
361366
tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e')));
362367
tester.pumpWidget(builder());
368+
renderLine = findRenderEditableLine(tester);
369+
endpoints = renderLine.getEndpointsForSelection(inputValue.selection);
370+
tester.tapAt(endpoints[0].point + new Offset(1.0, 1.0));
371+
tester.pumpWidget(builder());
363372

364373
// PASTE right before the 'e'.
365374
tester.tap(find.text('PASTE'));

0 commit comments

Comments
 (0)