// Copyright 2014 The Flutter 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 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'basic.dart'; import 'constants.dart'; import 'container.dart'; import 'editable_text.dart'; import 'framework.dart'; import 'gesture_detector.dart'; import 'overlay.dart'; import 'ticker_provider.dart'; import 'transitions.dart'; import 'visibility.dart'; export 'package:flutter/services.dart' show TextSelectionDelegate; /// A duration that controls how often the drag selection update callback is /// called. const Duration _kDragSelectionUpdateThrottle = Duration(milliseconds: 50); /// 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 TextSelectionOverlayChanged = void Function(TextEditingValue value, Rect caretRect); /// Signature for when a pointer that's dragging to select text has moved again. /// /// The first argument [startDetails] contains the details of the event that /// initiated the dragging. /// /// The second argument [updateDetails] contains the details of the current /// pointer movement. It's the same as the one passed to [DragGestureRecognizer.onUpdate]. /// /// This signature is different from [GestureDragUpdateCallback] to make it /// easier for various text fields to use [TextSelectionGestureDetector] without /// having to store the start position. typedef DragSelectionUpdateCallback = void Function(DragStartDetails startDetails, DragUpdateDetails updateDetails); /// 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); /// Get the anchor point of the handle relative to itself. The anchor point is /// the point that is aligned with a specific point in the text. A handle /// often visually "points to" that location. Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight); /// Builds a toolbar near a text selection. /// /// Typically displays buttons for copying and pasting text. /// /// [globalEditableRegion] is the TextField size of the global coordinate system /// in logical pixels. /// /// [textLineHeight] is the `preferredLineHeight` of the [RenderEditable] we /// are building a toolbar for. /// /// The [position] is a general calculation midpoint parameter of the toolbar. /// If you want more detailed position information, can use [endpoints] /// to calculate it. Widget buildToolbar( BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ); /// Returns the size of the selection handle. Size getHandleSize(double textLineHeight); /// 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.cutEnabled && !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.copyEnabled && !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 delegate.pasteEnabled; } /// 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.selectAllEnabled && 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(ClipboardData( text: value.selection.textInside(value.text), )); delegate.textEditingValue = TextEditingValue( text: value.selection.textBefore(value.text) + value.selection.textAfter(value.text), selection: 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(ClipboardData( text: value.selection.textInside(value.text), )); delegate.textEditingValue = TextEditingValue( text: value.text, selection: 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<void> 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 = TextEditingValue( text: value.selection.textBefore(value.text) + data.text + value.selection.textAfter(value.text), selection: 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 = TextEditingValue( text: delegate.textEditingValue.text, selection: 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.toolbarLayerLink, @required this.startHandleLayerLink, @required this.endHandleLayerLink, @required this.renderObject, this.selectionControls, bool handlesVisible = false, this.selectionDelegate, this.dragStartBehavior = DragStartBehavior.start, this.onSelectionHandleTapped, }) : assert(value != null), assert(context != null), assert(handlesVisible != null), _handlesVisible = handlesVisible, _value = value { final OverlayState overlay = Overlay.of(context, rootOverlay: true); assert(overlay != null, 'No Overlay widget exists above $context.\n' 'Usually the Navigator created by WidgetsApp provides the overlay. Perhaps your ' 'app content was created above the Navigator with the WidgetsApp builder parameter.'); _toolbarController = AnimationController(duration: fadeDuration, 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 toolbarLayerLink; /// The objects supplied to the [CompositedTransformTarget] that wraps the /// location of start selection handle. final LayerLink startHandleLayerLink; /// The objects supplied to the [CompositedTransformTarget] that wraps the /// location of end selection handle. final LayerLink endHandleLayerLink; // 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; /// Determines the way that drag start behavior is handled. /// /// If set to [DragStartBehavior.start], handle drag behavior will /// begin upon the detection of a drag gesture. If set to /// [DragStartBehavior.down] it will begin when a down event is first detected. /// /// In general, setting this to [DragStartBehavior.start] will make drag /// animation smoother and setting it to [DragStartBehavior.down] will make /// drag behavior feel slightly more reactive. /// /// By default, the drag start behavior is [DragStartBehavior.start]. /// /// See also: /// /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors. final DragStartBehavior dragStartBehavior; /// {@template flutter.widgets.textSelection.onSelectionHandleTapped} /// A callback that's invoked when a selection handle is tapped. /// /// Both regular taps and long presses invoke this callback, but a drag /// gesture won't. /// {@endtemplate} final VoidCallback onSelectionHandleTapped; /// Controls the fade-in and fade-out animations for the toolbar and handles. static const Duration fadeDuration = Duration(milliseconds: 150); AnimationController _toolbarController; Animation<double> get _toolbarOpacity => _toolbarController.view; /// Retrieve current value. @visibleForTesting TextEditingValue get value => _value; 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; /// Whether selection handles are visible. /// /// Set to false if you want to hide the handles. Use this property to show or /// hide the handle without rebuilding them. /// /// 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). /// /// Defaults to false. bool get handlesVisible => _handlesVisible; bool _handlesVisible = false; set handlesVisible(bool visible) { assert(visible != null); if (_handlesVisible == visible) return; _handlesVisible = visible; // If we are in build state, it will be too late to update visibility. // We will need to schedule the build in next frame. if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) { SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild); } else { _markNeedsBuild(); } } /// Builds the handles by inserting them into the [context]'s overlay. void showHandles() { assert(_handles == null); _handles = <OverlayEntry>[ OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)), OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)), ]; Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insertAll(_handles); } /// Destroys the handles by removing them from overlay. void hideHandles() { if (_handles != null) { _handles[0].remove(); _handles[1].remove(); _handles = null; } } /// Shows the toolbar by inserting it into the [context]'s overlay. void showToolbar() { assert(_toolbar == null); _toolbar = OverlayEntry(builder: _buildToolbar); Overlay.of(context, rootOverlay: true, 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 && handlesVisible; /// Whether the toolbar is currently visible. bool get toolbarIsVisible => _toolbar != null; /// Hides the entire overlay including the toolbar and the handles. void hide() { if (_handles != null) { _handles[0].remove(); _handles[1].remove(); _handles = null; } if (_toolbar != null) { hideToolbar(); } } /// Hides the toolbar part of the overlay. /// /// To hide the whole overlay, see [hide]. void hideToolbar() { assert(_toolbar != null); _toolbarController.stop(); _toolbar.remove(); _toolbar = null; } /// Final cleanup. void dispose() { hide(); _toolbarController.dispose(); } Widget _buildHandle(BuildContext context, _TextSelectionHandlePosition position) { if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) || selectionControls == null) return Container(); // hide the second handle when collapsed return Visibility( visible: handlesVisible, child: _TextSelectionHandleOverlay( onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); }, onSelectionHandleTapped: onSelectionHandleTapped, startHandleLayerLink: startHandleLayerLink, endHandleLayerLink: endHandleLayerLink, renderObject: renderObject, selection: _selection, selectionControls: selectionControls, position: position, dragStartBehavior: dragStartBehavior, )); } Widget _buildToolbar(BuildContext context) { if (selectionControls == null) return Container(); // Find the horizontal midpoint, just above the selected text. final List<TextSelectionPoint> endpoints = renderObject.getEndpointsForSelection(_selection); final Rect editingRegion = Rect.fromPoints( renderObject.localToGlobal(Offset.zero), renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)), ); final bool isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > renderObject.preferredLineHeight / 2; // If the selected text spans more than 1 line, horizontally center the toolbar. // Derived from both iOS and Android. final double midX = isMultiline ? editingRegion.width / 2 : (endpoints.first.point.dx + endpoints.last.point.dx) / 2; final Offset midpoint = Offset( midX, // The y-coordinate won't be made use of most likely. endpoints[0].point.dy - renderObject.preferredLineHeight, ); return FadeTransition( opacity: _toolbarOpacity, child: CompositedTransformFollower( link: toolbarLayerLink, showWhenUnlinked: false, offset: -editingRegion.topLeft, child: selectionControls.buildToolbar( context, editingRegion, renderObject.preferredLineHeight, midpoint, endpoints, 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); } } /// 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.startHandleLayerLink, @required this.endHandleLayerLink, @required this.renderObject, @required this.onSelectionHandleChanged, @required this.onSelectionHandleTapped, @required this.selectionControls, this.dragStartBehavior = DragStartBehavior.start, }) : super(key: key); final TextSelection selection; final _TextSelectionHandlePosition position; final LayerLink startHandleLayerLink; final LayerLink endHandleLayerLink; final RenderEditable renderObject; final ValueChanged<TextSelection> onSelectionHandleChanged; final VoidCallback onSelectionHandleTapped; final TextSelectionControls selectionControls; final DragStartBehavior dragStartBehavior; @override _TextSelectionHandleOverlayState createState() => _TextSelectionHandleOverlayState(); ValueListenable<bool> get _visibility { switch (position) { case _TextSelectionHandlePosition.start: return renderObject.selectionStartInViewport; case _TextSelectionHandlePosition.end: return renderObject.selectionEndInViewport; } return null; } } class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin { Offset _dragPosition; AnimationController _controller; Animation<double> get _opacity => _controller.view; @override void initState() { super.initState(); _controller = AnimationController(duration: TextSelectionOverlay.fadeDuration, vsync: this); _handleVisibilityChanged(); widget._visibility.addListener(_handleVisibilityChanged); } void _handleVisibilityChanged() { if (widget._visibility.value) { _controller.forward(); } else { _controller.reverse(); } } @override void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { super.didUpdateWidget(oldWidget); oldWidget._visibility.removeListener(_handleVisibilityChanged); _handleVisibilityChanged(); widget._visibility.addListener(_handleVisibilityChanged); } @override void dispose() { widget._visibility.removeListener(_handleVisibilityChanged); _controller.dispose(); super.dispose(); } void _handleDragStart(DragStartDetails details) { final Size handleSize = widget.selectionControls.getHandleSize( widget.renderObject.preferredLineHeight, ); _dragPosition = details.globalPosition + Offset(0.0, -handleSize.height); } void _handleDragUpdate(DragUpdateDetails details) { _dragPosition += details.delta; final TextPosition position = widget.renderObject.getPositionForPoint(_dragPosition); if (widget.selection.isCollapsed) { widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); return; } TextSelection newSelection; switch (widget.position) { case _TextSelectionHandlePosition.start: newSelection = TextSelection( baseOffset: position.offset, extentOffset: widget.selection.extentOffset, ); break; case _TextSelectionHandlePosition.end: newSelection = 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() { if (widget.onSelectionHandleTapped != null) widget.onSelectionHandleTapped(); } @override Widget build(BuildContext context) { LayerLink layerLink; TextSelectionHandleType type; switch (widget.position) { case _TextSelectionHandlePosition.start: layerLink = widget.startHandleLayerLink; type = _chooseType( widget.renderObject.textDirection, TextSelectionHandleType.left, TextSelectionHandleType.right, ); break; case _TextSelectionHandlePosition.end: // For collapsed selections, we shouldn't be building the [end] handle. assert(!widget.selection.isCollapsed); layerLink = widget.endHandleLayerLink; type = _chooseType( widget.renderObject.textDirection, TextSelectionHandleType.right, TextSelectionHandleType.left, ); break; } final Offset handleAnchor = widget.selectionControls.getHandleAnchor( type, widget.renderObject.preferredLineHeight, ); final Size handleSize = widget.selectionControls.getHandleSize( widget.renderObject.preferredLineHeight, ); final Rect handleRect = Rect.fromLTWH( -handleAnchor.dx, -handleAnchor.dy, handleSize.width, handleSize.height, ); // Make sure the GestureDetector is big enough to be easily interactive. final Rect interactiveRect = handleRect.expandToInclude( Rect.fromCircle(center: handleRect.center, radius: kMinInteractiveDimension/ 2), ); final RelativeRect padding = RelativeRect.fromLTRB( math.max((interactiveRect.width - handleRect.width) / 2, 0), math.max((interactiveRect.height - handleRect.height) / 2, 0), math.max((interactiveRect.width - handleRect.width) / 2, 0), math.max((interactiveRect.height - handleRect.height) / 2, 0), ); return CompositedTransformFollower( link: layerLink, offset: interactiveRect.topLeft, showWhenUnlinked: false, child: FadeTransition( opacity: _opacity, child: Container( alignment: Alignment.topLeft, width: interactiveRect.width, height: interactiveRect.height, child: GestureDetector( behavior: HitTestBehavior.translucent, dragStartBehavior: widget.dragStartBehavior, onPanStart: _handleDragStart, onPanUpdate: _handleDragUpdate, onTap: _handleTap, child: Padding( padding: EdgeInsets.only( left: padding.left, top: padding.top, right: padding.right, bottom: padding.bottom, ), child: widget.selectionControls.buildHandle( context, type, widget.renderObject.preferredLineHeight, ), ), ), ), ), ); } TextSelectionHandleType _chooseType( TextDirection textDirection, TextSelectionHandleType ltrType, TextSelectionHandleType rtlType, ) { if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed; assert(textDirection != null); switch (textDirection) { case TextDirection.ltr: return ltrType; case TextDirection.rtl: return rtlType; } return null; } } /// Delegate interface for the [TextSelectionGestureDetectorBuilder]. /// /// The interface is usually implemented by textfield implementations wrapping /// [EditableText], that use a [TextSelectionGestureDetectorBuilder] to build a /// [TextSelectionGestureDetector] for their [EditableText]. The delegate provides /// the builder with information about the current state of the textfield. /// Based on these information, the builder adds the correct gesture handlers /// to the gesture detector. /// /// See also: /// /// * [TextField], which implements this delegate for the Material textfield. /// * [CupertinoTextField], which implements this delegate for the Cupertino /// textfield. abstract class TextSelectionGestureDetectorBuilderDelegate { /// [GlobalKey] to the [EditableText] for which the /// [TextSelectionGestureDetectorBuilder] will build a [TextSelectionGestureDetector]. GlobalKey<EditableTextState> get editableTextKey; /// Whether the textfield should respond to force presses. bool get forcePressEnabled; /// Whether the user may select text in the textfield. bool get selectionEnabled; } /// Builds a [TextSelectionGestureDetector] to wrap an [EditableText]. /// /// The class implements sensible defaults for many user interactions /// with an [EditableText] (see the documentation of the various gesture handler /// methods, e.g. [onTapDown], [onFrocePress], etc.). Subclasses of /// [EditableTextSelectionHandlesProvider] can change the behavior performed in /// responds to these gesture events by overriding the corresponding handler /// methods of this class. /// /// The resulting [TextSelectionGestureDetector] to wrap an [EditableText] is /// obtained by calling [buildGestureDetector]. /// /// See also: /// /// * [TextField], which uses a subclass to implement the Material-specific /// gesture logic of an [EditableText]. /// * [CupertinoTextField], which uses a subclass to implement the /// Cupertino-specific gesture logic of an [EditableText]. class TextSelectionGestureDetectorBuilder { /// Creates a [TextSelectionGestureDetectorBuilder]. /// /// The [delegate] must not be null. TextSelectionGestureDetectorBuilder({ @required this.delegate, }) : assert(delegate != null); /// The delegate for this [TextSelectionGestureDetectorBuilder]. /// /// The delegate provides the builder with information about what actions can /// currently be performed on the textfield. Based on this, the builder adds /// the correct gesture handlers to the gesture detector. @protected final TextSelectionGestureDetectorBuilderDelegate delegate; /// Whether to show the selection toolbar. /// /// It is based on the signal source when a [onTapDown] is called. This getter /// will return true if current [onTapDown] event is triggered by a touch or /// a stylus. bool get shouldShowSelectionToolbar => _shouldShowSelectionToolbar; bool _shouldShowSelectionToolbar = true; /// The [State] of the [EditableText] for which the builder will provide a /// [TextSelectionGestureDetector]. @protected EditableTextState get editableText => delegate.editableTextKey.currentState; /// The [RenderObject] of the [EditableText] for which the builder will /// provide a [TextSelectionGestureDetector]. @protected RenderEditable get renderEditable => editableText.renderEditable; /// Handler for [TextSelectionGestureDetector.onTapDown]. /// /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets /// [shouldShowSelectionToolbar] to true if the tap was initiated by a finger or stylus. /// /// See also: /// /// * [TextSelectionGestureDetector.onTapDown], which triggers this callback. @protected void onTapDown(TapDownDetails details) { renderEditable.handleTapDown(details); // The selection overlay should only be shown when the user is interacting // through a touch screen (via either a finger or a stylus). A mouse shouldn't // trigger the selection overlay. // For backwards-compatibility, we treat a null kind the same as touch. final PointerDeviceKind kind = details.kind; _shouldShowSelectionToolbar = kind == null || kind == PointerDeviceKind.touch || kind == PointerDeviceKind.stylus; } /// Handler for [TextSelectionGestureDetector.onForcePressStart]. /// /// By default, it selects the word at the position of the force press, /// if selection is enabled. /// /// This callback is only applicable when force press is enabled. /// /// See also: /// /// * [TextSelectionGestureDetector.onForcePressStart], which triggers this /// callback. @protected void onForcePressStart(ForcePressDetails details) { assert(delegate.forcePressEnabled); _shouldShowSelectionToolbar = true; if (delegate.selectionEnabled) { renderEditable.selectWordsInRange( from: details.globalPosition, cause: SelectionChangedCause.forcePress, ); } } /// Handler for [TextSelectionGestureDetector.onForcePressEnd]. /// /// By default, it selects words in the range specified in [details] and shows /// toolbar if it is necessary. /// /// This callback is only applicable when force press is enabled. /// /// See also: /// /// * [TextSelectionGestureDetector.onForcePressEnd], which triggers this /// callback. @protected void onForcePressEnd(ForcePressDetails details) { assert(delegate.forcePressEnabled); renderEditable.selectWordsInRange( from: details.globalPosition, cause: SelectionChangedCause.forcePress, ); if (shouldShowSelectionToolbar) editableText.showToolbar(); } /// Handler for [TextSelectionGestureDetector.onSingleTapUp]. /// /// By default, it selects word edge if selection is enabled. /// /// See also: /// /// * [TextSelectionGestureDetector.onSingleTapUp], which triggers /// this callback. @protected void onSingleTapUp(TapUpDetails details) { if (delegate.selectionEnabled) { renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); } } /// Handler for [TextSelectionGestureDetector.onSingleTapCancel]. /// /// By default, it services as place holder to enable subclass override. /// /// See also: /// /// * [TextSelectionGestureDetector.onSingleTapCancel], which triggers /// this callback. @protected void onSingleTapCancel() {/* Subclass should override this method if needed. */} /// Handler for [TextSelectionGestureDetector.onSingleLongTapStart]. /// /// By default, it selects text position specified in [details] if selection /// is enabled. /// /// See also: /// /// * [TextSelectionGestureDetector.onSingleLongTapStart], which triggers /// this callback. @protected void onSingleLongTapStart(LongPressStartDetails details) { if (delegate.selectionEnabled) { renderEditable.selectPositionAt( from: details.globalPosition, cause: SelectionChangedCause.longPress, ); } } /// Handler for [TextSelectionGestureDetector.onSingleLongTapMoveUpdate]. /// /// By default, it updates the selection location specified in [details] if /// selection is enabled. /// /// See also: /// /// * [TextSelectionGestureDetector.onSingleLongTapMoveUpdate], which /// triggers this callback. @protected void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { if (delegate.selectionEnabled) { renderEditable.selectPositionAt( from: details.globalPosition, cause: SelectionChangedCause.longPress, ); } } /// Handler for [TextSelectionGestureDetector.onSingleLongTapEnd]. /// /// By default, it shows toolbar if necessary. /// /// See also: /// /// * [TextSelectionGestureDetector.onSingleLongTapEnd], which triggers this /// callback. @protected void onSingleLongTapEnd(LongPressEndDetails details) { if (shouldShowSelectionToolbar) editableText.showToolbar(); } /// Handler for [TextSelectionGestureDetector.onDoubleTapDown]. /// /// By default, it selects a word through [renderEditable.selectWord] if /// selectionEnabled and shows toolbar if necessary. /// /// See also: /// /// * [TextSelectionGestureDetector.onDoubleTapDown], which triggers this /// callback. @protected void onDoubleTapDown(TapDownDetails details) { if (delegate.selectionEnabled) { renderEditable.selectWord(cause: SelectionChangedCause.tap); if (shouldShowSelectionToolbar) editableText.showToolbar(); } } /// Handler for [TextSelectionGestureDetector.onDragSelectionStart]. /// /// By default, it selects a text position specified in [details]. /// /// See also: /// /// * [TextSelectionGestureDetector.onDragSelectionStart], which triggers /// this callback. @protected void onDragSelectionStart(DragStartDetails details) { renderEditable.selectPositionAt( from: details.globalPosition, cause: SelectionChangedCause.drag, ); } /// Handler for [TextSelectionGestureDetector.onDragSelectionUpdate]. /// /// By default, it updates the selection location specified in [details]. /// /// See also: /// /// * [TextSelectionGestureDetector.onDragSelectionUpdate], which triggers /// this callback./lib/src/material/text_field.dart @protected void onDragSelectionUpdate(DragStartDetails startDetails, DragUpdateDetails updateDetails) { renderEditable.selectPositionAt( from: startDetails.globalPosition, to: updateDetails.globalPosition, cause: SelectionChangedCause.drag, ); } /// Handler for [TextSelectionGestureDetector.onDragSelectionEnd]. /// /// By default, it services as place holder to enable subclass override. /// /// See also: /// /// * [TextSelectionGestureDetector.onDragSelectionEnd], which triggers this /// callback. @protected void onDragSelectionEnd(DragEndDetails details) {/* Subclass should override this method if needed. */} /// Returns a [TextSelectionGestureDetector] configured with the handlers /// provided by this builder. /// /// The [child] or its subtree should contain [EditableText]. Widget buildGestureDetector({ Key key, HitTestBehavior behavior, Widget child, }) { return TextSelectionGestureDetector( key: key, onTapDown: onTapDown, onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, onSingleTapUp: onSingleTapUp, onSingleTapCancel: onSingleTapCancel, onSingleLongTapStart: onSingleLongTapStart, onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, onSingleLongTapEnd: onSingleLongTapEnd, onDoubleTapDown: onDoubleTapDown, onDragSelectionStart: onDragSelectionStart, onDragSelectionUpdate: onDragSelectionUpdate, onDragSelectionEnd: onDragSelectionEnd, behavior: behavior, child: child, ); } } /// A gesture detector to respond to non-exclusive event chains for a text field. /// /// An ordinary [GestureDetector] configured to handle events like tap and /// double tap will only recognize one or the other. This widget detects both: /// first the tap and then, if another tap down occurs within a time limit, the /// double tap. /// /// See also: /// /// * [TextField], a Material text field which uses this gesture detector. /// * [CupertinoTextField], a Cupertino text field which uses this gesture /// detector. class TextSelectionGestureDetector extends StatefulWidget { /// Create a [TextSelectionGestureDetector]. /// /// Multiple callbacks can be called for one sequence of input gesture. /// The [child] parameter must not be null. const TextSelectionGestureDetector({ Key key, this.onTapDown, this.onForcePressStart, this.onForcePressEnd, this.onSingleTapUp, this.onSingleTapCancel, this.onSingleLongTapStart, this.onSingleLongTapMoveUpdate, this.onSingleLongTapEnd, this.onDoubleTapDown, this.onDragSelectionStart, this.onDragSelectionUpdate, this.onDragSelectionEnd, this.behavior, @required this.child, }) : assert(child != null), super(key: key); /// Called for every tap down including every tap down that's part of a /// double click or a long press, except touches that include enough movement /// to not qualify as taps (e.g. pans and flings). final GestureTapDownCallback onTapDown; /// Called when a pointer has tapped down and the force of the pointer has /// just become greater than [ForcePressGestureDetector.startPressure]. final GestureForcePressStartCallback onForcePressStart; /// Called when a pointer that had previously triggered [onForcePressStart] is /// lifted off the screen. final GestureForcePressEndCallback onForcePressEnd; /// Called for each distinct tap except for every second tap of a double tap. /// For example, if the detector was configured [onSingleTapDown] and /// [onDoubleTapDown], three quick taps would be recognized as a single tap /// down, followed by a double tap down, followed by a single tap down. final GestureTapUpCallback onSingleTapUp; /// Called for each touch that becomes recognized as a gesture that is not a /// short tap, such as a long tap or drag. It is called at the moment when /// another gesture from the touch is recognized. final GestureTapCancelCallback onSingleTapCancel; /// Called for a single long tap that's sustained for longer than /// [kLongPressTimeout] but not necessarily lifted. Not called for a /// double-tap-hold, which calls [onDoubleTapDown] instead. final GestureLongPressStartCallback onSingleLongTapStart; /// Called after [onSingleLongTapStart] when the pointer is dragged. final GestureLongPressMoveUpdateCallback onSingleLongTapMoveUpdate; /// Called after [onSingleLongTapStart] when the pointer is lifted. final GestureLongPressEndCallback onSingleLongTapEnd; /// Called after a momentary hold or a short tap that is close in space and /// time (within [kDoubleTapTimeout]) to a previous short tap. final GestureTapDownCallback onDoubleTapDown; /// Called when a mouse starts dragging to select text. final GestureDragStartCallback onDragSelectionStart; /// Called repeatedly as a mouse moves while dragging. /// /// The frequency of calls is throttled to avoid excessive text layout /// operations in text fields. The throttling is controlled by the constant /// [_kDragSelectionUpdateThrottle]. final DragSelectionUpdateCallback onDragSelectionUpdate; /// Called when a mouse that was previously dragging is released. final GestureDragEndCallback onDragSelectionEnd; /// How this gesture detector should behave during hit testing. /// /// This defaults to [HitTestBehavior.deferToChild]. final HitTestBehavior behavior; /// Child below this widget. final Widget child; @override State<StatefulWidget> createState() => _TextSelectionGestureDetectorState(); } class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetector> { // Counts down for a short duration after a previous tap. Null otherwise. Timer _doubleTapTimer; Offset _lastTapOffset; // True if a second tap down of a double tap is detected. Used to discard // subsequent tap up / tap hold of the same tap. bool _isDoubleTap = false; @override void dispose() { _doubleTapTimer?.cancel(); _dragUpdateThrottleTimer?.cancel(); super.dispose(); } // The down handler is force-run on success of a single tap and optimistically // run before a long press success. void _handleTapDown(TapDownDetails details) { if (widget.onTapDown != null) { widget.onTapDown(details); } // This isn't detected as a double tap gesture in the gesture recognizer // because it's 2 single taps, each of which may do different things depending // on whether it's a single tap, the first tap of a double tap, the second // tap held down, a clean double tap etc. if (_doubleTapTimer != null && _isWithinDoubleTapTolerance(details.globalPosition)) { // If there was already a previous tap, the second down hold/tap is a // double tap down. if (widget.onDoubleTapDown != null) { widget.onDoubleTapDown(details); } _doubleTapTimer.cancel(); _doubleTapTimeout(); _isDoubleTap = true; } } void _handleTapUp(TapUpDetails details) { if (!_isDoubleTap) { if (widget.onSingleTapUp != null) { widget.onSingleTapUp(details); } _lastTapOffset = details.globalPosition; _doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); } _isDoubleTap = false; } void _handleTapCancel() { if (widget.onSingleTapCancel != null) { widget.onSingleTapCancel(); } } DragStartDetails _lastDragStartDetails; DragUpdateDetails _lastDragUpdateDetails; Timer _dragUpdateThrottleTimer; void _handleDragStart(DragStartDetails details) { assert(_lastDragStartDetails == null); _lastDragStartDetails = details; if (widget.onDragSelectionStart != null) { widget.onDragSelectionStart(details); } } void _handleDragUpdate(DragUpdateDetails details) { _lastDragUpdateDetails = details; // Only schedule a new timer if there's no one pending. _dragUpdateThrottleTimer ??= Timer(_kDragSelectionUpdateThrottle, _handleDragUpdateThrottled); } /// Drag updates are being throttled to avoid excessive text layouts in text /// fields. The frequency of invocations is controlled by the constant /// [_kDragSelectionUpdateThrottle]. /// /// Once the drag gesture ends, any pending drag update will be fired /// immediately. See [_handleDragEnd]. void _handleDragUpdateThrottled() { assert(_lastDragStartDetails != null); assert(_lastDragUpdateDetails != null); if (widget.onDragSelectionUpdate != null) { widget.onDragSelectionUpdate(_lastDragStartDetails, _lastDragUpdateDetails); } _dragUpdateThrottleTimer = null; _lastDragUpdateDetails = null; } void _handleDragEnd(DragEndDetails details) { assert(_lastDragStartDetails != null); if (_dragUpdateThrottleTimer != null) { // If there's already an update scheduled, trigger it immediately and // cancel the timer. _dragUpdateThrottleTimer.cancel(); _handleDragUpdateThrottled(); } if (widget.onDragSelectionEnd != null) { widget.onDragSelectionEnd(details); } _dragUpdateThrottleTimer = null; _lastDragStartDetails = null; _lastDragUpdateDetails = null; } void _forcePressStarted(ForcePressDetails details) { _doubleTapTimer?.cancel(); _doubleTapTimer = null; if (widget.onForcePressStart != null) widget.onForcePressStart(details); } void _forcePressEnded(ForcePressDetails details) { if (widget.onForcePressEnd != null) widget.onForcePressEnd(details); } void _handleLongPressStart(LongPressStartDetails details) { if (!_isDoubleTap && widget.onSingleLongTapStart != null) { widget.onSingleLongTapStart(details); } } void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) { widget.onSingleLongTapMoveUpdate(details); } } void _handleLongPressEnd(LongPressEndDetails details) { if (!_isDoubleTap && widget.onSingleLongTapEnd != null) { widget.onSingleLongTapEnd(details); } _isDoubleTap = false; } void _doubleTapTimeout() { _doubleTapTimer = null; _lastTapOffset = null; } bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { assert(secondTapOffset != null); if (_lastTapOffset == null) { return false; } final Offset difference = secondTapOffset - _lastTapOffset; return difference.distance <= kDoubleTapSlop; } @override Widget build(BuildContext context) { final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{}; // Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector // can receive the same tap events that a selection handle placed visually // on top of it also receives. gestures[_TransparentTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>( () => _TransparentTapGestureRecognizer(debugOwner: this), (_TransparentTapGestureRecognizer instance) { instance ..onTapDown = _handleTapDown ..onTapUp = _handleTapUp ..onTapCancel = _handleTapCancel; }, ); if (widget.onSingleLongTapStart != null || widget.onSingleLongTapMoveUpdate != null || widget.onSingleLongTapEnd != null) { gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( () => LongPressGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.touch), (LongPressGestureRecognizer instance) { instance ..onLongPressStart = _handleLongPressStart ..onLongPressMoveUpdate = _handleLongPressMoveUpdate ..onLongPressEnd = _handleLongPressEnd; }, ); } if (widget.onDragSelectionStart != null || widget.onDragSelectionUpdate != null || widget.onDragSelectionEnd != null) { // TODO(mdebbar): Support dragging in any direction (for multiline text). // https://github.com/flutter/flutter/issues/28676 gestures[HorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>( () => HorizontalDragGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.mouse), (HorizontalDragGestureRecognizer instance) { instance // Text selection should start from the position of the first pointer // down event. ..dragStartBehavior = DragStartBehavior.down ..onStart = _handleDragStart ..onUpdate = _handleDragUpdate ..onEnd = _handleDragEnd; }, ); } if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { gestures[ForcePressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>( () => ForcePressGestureRecognizer(debugOwner: this), (ForcePressGestureRecognizer instance) { instance ..onStart = widget.onForcePressStart != null ? _forcePressStarted : null ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; }, ); } return RawGestureDetector( gestures: gestures, excludeFromSemantics: true, behavior: widget.behavior, child: widget.child, ); } } // A TapGestureRecognizer which allows other GestureRecognizers to win in the // GestureArena. This means both _TransparentTapGestureRecognizer and other // GestureRecognizers can handle the same event. // // This enables proper handling of events on both the selection handle and the // underlying input, since there is significant overlap between the two given // the handle's padded hit area. For example, the selection handle needs to // handle single taps on itself, but double taps need to be handled by the // underlying input. class _TransparentTapGestureRecognizer extends TapGestureRecognizer { _TransparentTapGestureRecognizer({ Object debugOwner, }) : super(debugOwner: debugOwner); @override void rejectGesture(int pointer) { // Accept new gestures that another recognizer has already won. // Specifically, this needs to accept taps on the text selection handle on // behalf of the text field in order to handle double tap to select. It must // not accept other gestures like longpresses and drags that end outside of // the text field. if (state == GestureRecognizerState.ready) { acceptGesture(pointer); } else { super.rejectGesture(pointer); } } }