flutter/packages/flutter/lib/src/widgets/text_selection.dart
Ian Hickson 6d32b33997 Text selection handles track scrolled text fields (#10805)
Introduce CompositedTransformTarget and CompositedTransformFollower
widgets, corresponding render objects, and corresponding layers.

Adjust the way text fields work to use this.

Various changes I needed to debug the issues that came up.
2017-06-20 09:35:15 -07:00

445 lines
15 KiB
Dart

// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart';
import 'basic.dart';
import 'container.dart';
import 'editable_text.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'overlay.dart';
import 'transitions.dart';
/// Which type of selection handle to be displayed.
///
/// With mixed-direction text, both handles may be the same type. Examples:
///
/// * LTR text: 'the <quick brown> fox':
/// The '<' is drawn with the [left] type, the '>' with the [right]
///
/// * RTL text: 'xof <nworb kciuq> eht':
/// Same as above.
///
/// * mixed text: '<the nwor<b quick fox'
/// Here 'the b' is selected, but 'brown' is RTL. Both are drawn with the
/// [left] type.
enum TextSelectionHandleType {
/// The selection handle is to the left of the selection end point.
left,
/// The selection handle is to the right of the selection end point.
right,
/// The start and end of the selection are co-incident at this point.
collapsed,
}
/// The text position that a give selection handle manipulates. Dragging the
/// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { start, end }
/// Signature for reporting changes to the selection component of a
/// [TextEditingValue] for the purposes of a [TextSelectionOverlay]. The
/// [caretRect] argument gives the location of the caret in the coordinate space
/// of the [RenderBox] given by the [TextSelectionOverlay.renderObject].
///
/// Used by [TextSelectionOverlay.onSelectionOverlayChanged].
typedef void TextSelectionOverlayChanged(TextEditingValue value, Rect caretRect);
/// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget.
abstract class TextSelectionDelegate {
/// Gets the current text input.
TextEditingValue get textEditingValue;
/// Sets the current text input (replaces the whole line).
set textEditingValue(TextEditingValue value);
/// Hides the text selection toolbar.
void hideToolbar();
}
/// An interface for building the selection UI, to be provided by the
/// implementor of the toolbar widget.
abstract class TextSelectionControls {
/// Builds a selection handle of the given type.
Widget buildHandle(BuildContext context, TextSelectionHandleType type);
/// Builds a toolbar near a text selection.
///
/// Typically displays buttons for copying and pasting text.
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate);
/// Returns the size of the selection handle.
Size get handleSize;
}
/// An object that manages a pair of text selection handles.
///
/// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext].
class TextSelectionOverlay implements TextSelectionDelegate {
/// Creates an object that manages overly entries for selection handles.
///
/// The [context] must not be null and must have an [Overlay] as an ancestor.
TextSelectionOverlay({
@required TextEditingValue value,
@required this.context,
this.debugRequiredFor,
@required this.layerLink,
@required this.renderObject,
this.onSelectionOverlayChanged,
this.selectionControls,
}): assert(value != null),
assert(context != null),
_value = value {
final OverlayState overlay = Overlay.of(context);
assert(overlay != null);
_handleController = new AnimationController(duration: _kFadeDuration, vsync: overlay);
_toolbarController = new AnimationController(duration: _kFadeDuration, vsync: overlay);
}
/// The context in which the selection handles should appear.
///
/// This context must have an [Overlay] as an ancestor because this object
/// will display the text selection handles in that [Overlay].
final BuildContext context;
/// Debugging information for explaining why the [Overlay] is required.
final Widget debugRequiredFor;
/// The object supplied to the [CompositedTransformTarget] that wraps the text
/// field.
final LayerLink layerLink;
// TODO(mpcomplete): what if the renderObject is removed or replaced, or
// moves? Not sure what cases I need to handle, or how to handle them.
/// The editable line in which the selected text is being displayed.
final RenderEditable renderObject;
/// Called when the the selection changes.
///
/// For example, if the use drags one of the selection handles, this function
/// will be called with a new input value with an updated selection.
final TextSelectionOverlayChanged onSelectionOverlayChanged;
/// Builds text selection handles and toolbar.
final TextSelectionControls selectionControls;
/// Controls the fade-in animations.
static const Duration _kFadeDuration = const Duration(milliseconds: 150);
AnimationController _handleController;
AnimationController _toolbarController;
Animation<double> get _handleOpacity => _handleController.view;
Animation<double> get _toolbarOpacity => _toolbarController.view;
TextEditingValue _value;
/// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed.
List<OverlayEntry> _handles;
/// A copy/paste toolbar.
OverlayEntry _toolbar;
TextSelection get _selection => _value.selection;
/// Shows the handles by inserting them into the [context]'s overlay.
void showHandles() {
assert(_handles == null);
_handles = <OverlayEntry>[
new OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)),
new OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)),
];
Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
_handleController.forward(from: 0.0);
}
/// 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);
_toolbarController.forward(from: 0.0);
}
/// Updates the overlay after the selection has changed.
///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
/// until the post-frame callbacks phase. Otherwise the update is done
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
void update(TextEditingValue newValue) {
if (_value == newValue)
return;
_value = newValue;
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild);
} else {
_markNeedsBuild();
}
}
/// Causes the overlay to update its rendering.
///
/// This is intended to be called when the [renderObject] may have changed its
/// text metrics (e.g. because the text was scrolled).
void updateForScroll() {
_markNeedsBuild();
}
void _markNeedsBuild([Duration duration]) {
if (_handles != null) {
_handles[0].markNeedsBuild();
_handles[1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
}
/// Hides the overlay.
void hide() {
if (_handles != null) {
_handles[0].remove();
_handles[1].remove();
_handles = null;
}
_toolbar?.remove();
_toolbar = null;
_handleController.stop();
_toolbarController.stop();
}
/// Final cleanup.
void dispose() {
hide();
_handleController.dispose();
_toolbarController.dispose();
}
Widget _buildHandle(BuildContext context, _TextSelectionHandlePosition position) {
if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) ||
selectionControls == null)
return new Container(); // hide the second handle when collapsed
return new FadeTransition(
opacity: _handleOpacity,
child: new _TextSelectionHandleOverlay(
onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
onSelectionHandleTapped: _handleSelectionHandleTapped,
layerLink: layerLink,
renderObject: renderObject,
selection: _selection,
selectionControls: selectionControls,
position: position,
)
);
}
Widget _buildToolbar(BuildContext context) {
if (selectionControls == null)
return new Container();
// Find the horizontal midpoint, just above the selected text.
final List<TextSelectionPoint> endpoints = renderObject.getEndpointsForSelection(_selection);
final Offset midpoint = new Offset(
(endpoints.length == 1) ?
endpoints[0].point.dx :
(endpoints[0].point.dx + endpoints[1].point.dx) / 2.0,
endpoints[0].point.dy - renderObject.size.height,
);
final Rect editingRegion = new Rect.fromPoints(
renderObject.localToGlobal(Offset.zero),
renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)),
);
return new FadeTransition(
opacity: _toolbarOpacity,
child: new CompositedTransformFollower(
link: layerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: selectionControls.buildToolbar(context, editingRegion, midpoint, this),
),
);
}
void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
Rect caretRect;
switch (position) {
case _TextSelectionHandlePosition.start:
caretRect = renderObject.getLocalRectForCaret(newSelection.base);
break;
case _TextSelectionHandlePosition.end:
caretRect = renderObject.getLocalRectForCaret(newSelection.extent);
break;
}
update(_value.copyWith(selection: newSelection, composing: TextRange.empty));
if (onSelectionOverlayChanged != null)
onSelectionOverlayChanged(_value, caretRect);
}
void _handleSelectionHandleTapped() {
if (_value.selection.isCollapsed) {
if (_toolbar != null) {
_toolbar?.remove();
_toolbar = null;
} else {
showToolbar();
}
}
}
@override
TextEditingValue get textEditingValue => _value;
@override
set textEditingValue(TextEditingValue newValue) {
update(newValue);
if (onSelectionOverlayChanged != null) {
final Rect caretRect = renderObject.getLocalRectForCaret(newValue.selection.extent);
onSelectionOverlayChanged(newValue, caretRect);
}
}
@override
void hideToolbar() {
hide();
}
}
/// This widget represents a single draggable text selection handle.
class _TextSelectionHandleOverlay extends StatefulWidget {
const _TextSelectionHandleOverlay({
Key key,
@required this.selection,
@required this.position,
@required this.layerLink,
@required this.renderObject,
@required this.onSelectionHandleChanged,
@required this.onSelectionHandleTapped,
@required this.selectionControls
}) : super(key: key);
final TextSelection selection;
final _TextSelectionHandlePosition position;
final LayerLink layerLink;
final RenderEditable renderObject;
final ValueChanged<TextSelection> onSelectionHandleChanged;
final VoidCallback onSelectionHandleTapped;
final TextSelectionControls selectionControls;
@override
_TextSelectionHandleOverlayState createState() => new _TextSelectionHandleOverlayState();
}
class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay> {
Offset _dragPosition;
void _handleDragStart(DragStartDetails details) {
_dragPosition = details.globalPosition + new Offset(0.0, -widget.selectionControls.handleSize.height);
}
void _handleDragUpdate(DragUpdateDetails details) {
_dragPosition += details.delta;
final TextPosition position = widget.renderObject.getPositionForPoint(_dragPosition);
if (widget.selection.isCollapsed) {
widget.onSelectionHandleChanged(new TextSelection.fromPosition(position));
return;
}
TextSelection newSelection;
switch (widget.position) {
case _TextSelectionHandlePosition.start:
newSelection = new TextSelection(
baseOffset: position.offset,
extentOffset: widget.selection.extentOffset
);
break;
case _TextSelectionHandlePosition.end:
newSelection = new TextSelection(
baseOffset: widget.selection.baseOffset,
extentOffset: position.offset
);
break;
}
if (newSelection.baseOffset >= newSelection.extentOffset)
return; // don't allow order swapping.
widget.onSelectionHandleChanged(newSelection);
}
void _handleTap() {
widget.onSelectionHandleTapped();
}
@override
Widget build(BuildContext context) {
final List<TextSelectionPoint> endpoints = widget.renderObject.getEndpointsForSelection(widget.selection);
Offset point;
TextSelectionHandleType type;
switch (widget.position) {
case _TextSelectionHandlePosition.start:
point = endpoints[0].point;
type = _chooseType(endpoints[0], TextSelectionHandleType.left, TextSelectionHandleType.right);
break;
case _TextSelectionHandlePosition.end:
// [endpoints] will only contain 1 point for collapsed selections, in
// which case we shouldn't be building the [end] handle.
assert(endpoints.length == 2);
point = endpoints[1].point;
type = _chooseType(endpoints[1], TextSelectionHandleType.right, TextSelectionHandleType.left);
break;
}
return new CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
child: new GestureDetector(
onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate,
onTap: _handleTap,
child: new Stack(
children: <Widget>[
new Positioned(
left: point.dx,
top: point.dy,
child: widget.selectionControls.buildHandle(context, type),
),
],
),
),
);
}
TextSelectionHandleType _chooseType(
TextSelectionPoint endpoint,
TextSelectionHandleType ltrType,
TextSelectionHandleType rtlType
) {
if (widget.selection.isCollapsed)
return TextSelectionHandleType.collapsed;
assert(endpoint.direction != null);
switch (endpoint.direction) {
case TextDirection.ltr:
return ltrType;
case TextDirection.rtl:
return rtlType;
}
return null;
}
}