| // 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 'dart:async'; |
| |
| 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 KCIUQ fox' |
| /// |
| /// Here 'the QUICK B' is selected, but 'QUICK BROWN' is RTL. Both are drawn |
| /// with the [left] type. |
| /// |
| /// See also: |
| /// |
| /// * [TextDirection], which discusses left-to-right and right-to-left text in |
| /// more detail. |
| 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(); |
| |
| /// Brings the provided [TextPosition] into the visible area of the text |
| /// input. |
| void bringIntoView(TextPosition position); |
| } |
| |
| /// An interface for building the selection UI, to be provided by the |
| /// implementor of the toolbar widget. |
| /// |
| /// Override text operations such as [handleCut] if needed. |
| abstract class TextSelectionControls { |
| /// Builds a selection handle of the given type. |
| /// |
| /// The top left corner of this widget is positioned at the bottom of the |
| /// selection position. |
| Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight); |
| |
| /// 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; |
| |
| /// Whether the current selection of the text field managed by the given |
| /// `delegate` can be removed from the text field and placed into the |
| /// [Clipboard]. |
| /// |
| /// By default, false is returned when nothing is selected in the text field. |
| /// |
| /// Subclasses can use this to decide if they should expose the cut |
| /// functionality to the user. |
| bool canCut(TextSelectionDelegate delegate) { |
| return !delegate.textEditingValue.selection.isCollapsed; |
| } |
| |
| /// Whether the current selection of the text field managed by the given |
| /// `delegate` can be copied to the [Clipboard]. |
| /// |
| /// By default, false is returned when nothing is selected in the text field. |
| /// |
| /// Subclasses can use this to decide if they should expose the copy |
| /// functionality to the user. |
| bool canCopy(TextSelectionDelegate delegate) { |
| return !delegate.textEditingValue.selection.isCollapsed; |
| } |
| |
| /// Whether the current [Clipboard] content can be pasted into the text field |
| /// managed by the given `delegate`. |
| /// |
| /// Subclasses can use this to decide if they should expose the paste |
| /// functionality to the user. |
| bool canPaste(TextSelectionDelegate delegate) { |
| // TODO(goderbauer): return false when clipboard is empty, https://github.com/flutter/flutter/issues/11254 |
| return true; |
| } |
| |
| /// Whether the current selection of the text field managed by the given |
| /// `delegate` can be extended to include the entire content of the text |
| /// field. |
| /// |
| /// Subclasses can use this to decide if they should expose the select all |
| /// functionality to the user. |
| bool canSelectAll(TextSelectionDelegate delegate) { |
| return delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed; |
| } |
| |
| /// Copy the current selection of the text field managed by the given |
| /// `delegate` to the [Clipboard]. Then, remove the selected text from the |
| /// text field and hide the toolbar. |
| /// |
| /// This is called by subclasses when their cut affordance is activated by |
| /// the user. |
| void handleCut(TextSelectionDelegate delegate) { |
| final TextEditingValue value = delegate.textEditingValue; |
| Clipboard.setData(new ClipboardData( |
| text: value.selection.textInside(value.text), |
| )); |
| delegate.textEditingValue = new TextEditingValue( |
| text: value.selection.textBefore(value.text) |
| + value.selection.textAfter(value.text), |
| selection: new TextSelection.collapsed( |
| offset: value.selection.start |
| ), |
| ); |
| delegate.bringIntoView(delegate.textEditingValue.selection.extent); |
| delegate.hideToolbar(); |
| } |
| |
| /// Copy the current selection of the text field managed by the given |
| /// `delegate` to the [Clipboard]. Then, move the cursor to the end of the |
| /// text (collapsing the selection in the process), and hide the toolbar. |
| /// |
| /// This is called by subclasses when their copy affordance is activated by |
| /// the user. |
| void handleCopy(TextSelectionDelegate delegate) { |
| final TextEditingValue value = delegate.textEditingValue; |
| Clipboard.setData(new ClipboardData( |
| text: value.selection.textInside(value.text), |
| )); |
| delegate.textEditingValue = new TextEditingValue( |
| text: value.text, |
| selection: new TextSelection.collapsed(offset: value.selection.end), |
| ); |
| delegate.bringIntoView(delegate.textEditingValue.selection.extent); |
| delegate.hideToolbar(); |
| } |
| |
| /// Paste the current clipboard selection (obtained from [Clipboard]) into |
| /// the text field managed by the given `delegate`, replacing its current |
| /// selection, if any. Then, hide the toolbar. |
| /// |
| /// This is called by subclasses when their paste affordance is activated by |
| /// the user. |
| /// |
| /// This function is asynchronous since interacting with the clipboard is |
| /// asynchronous. Race conditions may exist with this API as currently |
| /// implemented. |
| // TODO(ianh): https://github.com/flutter/flutter/issues/11427 |
| Future<Null> handlePaste(TextSelectionDelegate delegate) async { |
| final TextEditingValue value = delegate.textEditingValue; // Snapshot the input before using `await`. |
| final ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain); |
| if (data != null) { |
| delegate.textEditingValue = new TextEditingValue( |
| text: value.selection.textBefore(value.text) |
| + data.text |
| + value.selection.textAfter(value.text), |
| selection: new TextSelection.collapsed( |
| offset: value.selection.start + data.text.length |
| ), |
| ); |
| } |
| delegate.bringIntoView(delegate.textEditingValue.selection.extent); |
| delegate.hideToolbar(); |
| } |
| |
| /// Adjust the selection of the text field managed by the given `delegate` so |
| /// that everything is selected. |
| /// |
| /// Does not hide the toolbar. |
| /// |
| /// This is called by subclasses when their select-all affordance is activated |
| /// by the user. |
| void handleSelectAll(TextSelectionDelegate delegate) { |
| delegate.textEditingValue = new TextEditingValue( |
| text: delegate.textEditingValue.text, |
| selection: new TextSelection( |
| baseOffset: 0, |
| extentOffset: delegate.textEditingValue.text.length |
| ), |
| ); |
| delegate.bringIntoView(delegate.textEditingValue.selection.extent); |
| } |
| } |
| |
| /// 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 { |
| /// 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.selectionControls, |
| this.selectionDelegate, |
| }): 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; |
| |
| /// Builds text selection handles and toolbar. |
| final TextSelectionControls selectionControls; |
| |
| /// The delegate for manipulating the current selection in the owning |
| /// text field. |
| final TextSelectionDelegate selectionDelegate; |
| |
| /// 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(); |
| } |
| |
| /// Whether the handles are currently visible. |
| bool get handlesAreVisible => _handles != null; |
| |
| /// Whether the toolbar is currently visible. |
| bool get toolbarIsVisible => _toolbar != null; |
| |
| /// 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.preferredLineHeight, |
| ); |
| |
| 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, selectionDelegate), |
| ), |
| ); |
| } |
| |
| void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) { |
| TextPosition textPosition; |
| switch (position) { |
| case _TextSelectionHandlePosition.start: |
| textPosition = newSelection.base; |
| break; |
| case _TextSelectionHandlePosition.end: |
| textPosition =newSelection.extent; |
| break; |
| } |
| selectionDelegate.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty); |
| selectionDelegate.bringIntoView(textPosition); |
| } |
| |
| void _handleSelectionHandleTapped() { |
| if (_value.selection.isCollapsed) { |
| if (_toolbar != null) { |
| _toolbar?.remove(); |
| _toolbar = null; |
| } else { |
| showToolbar(); |
| } |
| } |
| } |
| } |
| |
| /// 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, |
| widget.renderObject.preferredLineHeight, |
| ), |
| ), |
| ], |
| ), |
| ), |
| ); |
| } |
| |
| 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; |
| } |
| } |