| // 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 'binding.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 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); |
| |
| /// The type for a Function that builds a toolbar's container with the given |
| /// child. |
| /// |
| /// See also: |
| /// |
| /// * [TextSelectionToolbar.toolbarBuilder], which is of this type. |
| /// type. |
| /// * [CupertinoTextSelectionToolbar.toolbarBuilder], which is similar, but |
| /// for a Cupertino-style toolbar. |
| typedef ToolbarBuilder = Widget Function(BuildContext context, Widget child); |
| |
| /// ParentData that determines whether or not to paint the corresponding child. |
| /// |
| /// Used in the layout of the Cupertino and Material text selection menus, which |
| /// decide whether or not to paint their buttons after laying them out and |
| /// determining where they overflow. |
| class ToolbarItemsParentData extends ContainerBoxParentData<RenderBox> { |
| /// Whether or not this child is painted. |
| /// |
| /// Children in the selection toolbar may be laid out for measurement purposes |
| /// but not painted. This allows these children to be identified. |
| bool shouldPaint = false; |
| |
| @override |
| String toString() => '${super.toString()}; shouldPaint=$shouldPaint'; |
| } |
| |
| /// An interface for building the selection UI, to be provided by the |
| /// implementer 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, |
| ClipboardStatusNotifier clipboardStatus, |
| Offset? lastSecondaryTapDownPosition, |
| ); |
| |
| /// 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 text field managed by the given `delegate` supports pasting |
| /// from the clipboard. |
| /// |
| /// Subclasses can use this to decide if they should expose the paste |
| /// functionality to the user. |
| /// |
| /// This does not consider the contents of the clipboard. Subclasses may want |
| /// to, for example, disallow pasting when the clipboard contains an empty |
| /// string. |
| bool canPaste(TextSelectionDelegate delegate) { |
| 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; |
| } |
| |
| // TODO(justinmc): This and other methods should be ported to Actions and |
| // removed, along with their keyboard shortcut equivalents. |
| // https://github.com/flutter/flutter/issues/75004 |
| /// 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.userUpdateTextEditingValue( |
| TextEditingValue( |
| text: value.selection.textBefore(value.text) |
| + value.selection.textAfter(value.text), |
| selection: TextSelection.collapsed( |
| offset: value.selection.start |
| ) |
| ), |
| SelectionChangedCause.toolBar, |
| ); |
| 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, ClipboardStatusNotifier? clipboardStatus) { |
| final TextEditingValue value = delegate.textEditingValue; |
| Clipboard.setData(ClipboardData( |
| text: value.selection.textInside(value.text), |
| )); |
| clipboardStatus?.update(); |
| delegate.bringIntoView(delegate.textEditingValue.selection.extent); |
| |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.iOS: |
| // Hide the toolbar, but keep the selection and keep the handles. |
| delegate.hideToolbar(false); |
| return; |
| case TargetPlatform.macOS: |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| // Collapse the selection and hide the toolbar and handles. |
| delegate.userUpdateTextEditingValue( |
| TextEditingValue( |
| text: value.text, |
| selection: TextSelection.collapsed(offset: value.selection.end), |
| ), |
| SelectionChangedCause.toolBar, |
| ); |
| delegate.hideToolbar(); |
| return; |
| } |
| } |
| |
| /// 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.userUpdateTextEditingValue( |
| TextEditingValue( |
| text: value.selection.textBefore(value.text) |
| + data.text! |
| + value.selection.textAfter(value.text), |
| selection: TextSelection.collapsed( |
| offset: value.selection.start + data.text!.length |
| ), |
| ), |
| SelectionChangedCause.toolBar, |
| ); |
| } |
| 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.userUpdateTextEditingValue( |
| TextEditingValue( |
| text: delegate.textEditingValue.text, |
| selection: TextSelection( |
| baseOffset: 0, |
| extentOffset: delegate.textEditingValue.text.length, |
| ), |
| ), |
| SelectionChangedCause.toolBar, |
| ); |
| 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 overlay 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, |
| this.clipboardStatus, |
| }) : 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.TextSelectionOverlay.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; |
| |
| /// Maintains the status of the clipboard for determining if its contents can |
| /// be pasted or not. |
| /// |
| /// Useful because the actual value of the clipboard can only be checked |
| /// asynchronously (see [Clipboard.getData]). |
| final ClipboardStatusNotifier? clipboardStatus; |
| |
| /// Controls the fade-in and fade-out animations for the toolbar and handles. |
| static const Duration fadeDuration = Duration(milliseconds: 150); |
| |
| late 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() { |
| if (_handles != null) |
| return; |
| |
| _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) { |
| Widget handle; |
| if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) || |
| selectionControls == null) |
| handle = Container(); // hide the second handle when collapsed |
| else { |
| handle = 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, |
| ) |
| ); |
| } |
| return ExcludeSemantics( |
| child: handle, |
| ); |
| } |
| |
| 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 Directionality( |
| textDirection: Directionality.of(this.context), |
| child: FadeTransition( |
| opacity: _toolbarOpacity, |
| child: CompositedTransformFollower( |
| link: toolbarLayerLink, |
| showWhenUnlinked: false, |
| offset: -editingRegion.topLeft, |
| child: Builder( |
| builder: (BuildContext context) { |
| return selectionControls!.buildToolbar( |
| context, |
| editingRegion, |
| renderObject.preferredLineHeight, |
| midpoint, |
| endpoints, |
| selectionDelegate!, |
| clipboardStatus!, |
| renderObject.lastSecondaryTapDownPosition, |
| ); |
| }, |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) { |
| final TextPosition textPosition; |
| switch (position) { |
| case _TextSelectionHandlePosition.start: |
| textPosition = newSelection.base; |
| break; |
| case _TextSelectionHandlePosition.end: |
| textPosition = newSelection.extent; |
| break; |
| } |
| selectionDelegate!.userUpdateTextEditingValue( |
| _value.copyWith(selection: newSelection, composing: TextRange.empty), |
| SelectionChangedCause.drag, |
| ); |
| 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; |
| } |
| } |
| } |
| |
| class _TextSelectionHandleOverlayState |
| extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin { |
| late Offset _dragPosition; |
| |
| late 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; |
| } |
| |
| final 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) { |
| final LayerLink layerLink; |
| final 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; |
| } |
| } |
| } |
| |
| /// 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], [onForcePressStart], etc.). Subclasses of |
| /// [TextSelectionGestureDetectorBuilder] 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; |
| |
| /// Returns true iff lastSecondaryTapDownPosition was on selection. |
| bool get _lastSecondaryTapWasOnSelection { |
| assert(renderEditable.lastSecondaryTapDownPosition != null); |
| if (renderEditable.selection == null) { |
| return false; |
| } |
| |
| final TextPosition textPosition = renderEditable.getPositionForPoint( |
| renderEditable.lastSecondaryTapDownPosition!, |
| ); |
| |
| return renderEditable.selection!.base.offset <= textPosition.offset |
| && renderEditable.selection!.extent.offset >= textPosition.offset; |
| } |
| |
| /// 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.onSecondaryTap]. |
| /// |
| /// By default, selects the word if possible and shows the toolbar. |
| @protected |
| void onSecondaryTap() { |
| if (delegate.selectionEnabled) { |
| if (!_lastSecondaryTapWasOnSelection) { |
| renderEditable.selectWord(cause: SelectionChangedCause.tap); |
| } |
| if (shouldShowSelectionToolbar) { |
| editableText.hideToolbar(); |
| editableText.showToolbar(); |
| } |
| } |
| } |
| |
| /// Handler for [TextSelectionGestureDetector.onSecondaryTapDown]. |
| /// |
| /// See also: |
| /// |
| /// * [TextSelectionGestureDetector.onSecondaryTapDown], which triggers this |
| /// callback. |
| /// * [onSecondaryTap], which is typically called after this. |
| @protected |
| void onSecondaryTapDown(TapDownDetails details) { |
| renderEditable.handleSecondaryTapDown(details); |
| _shouldShowSelectionToolbar = true; |
| } |
| |
| /// 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) { |
| if (!delegate.selectionEnabled) |
| return; |
| final PointerDeviceKind? kind = details.kind; |
| _shouldShowSelectionToolbar = kind == null |
| || kind == PointerDeviceKind.touch |
| || kind == PointerDeviceKind.stylus; |
| |
| renderEditable.selectPositionAt( |
| from: details.globalPosition, |
| cause: SelectionChangedCause.drag, |
| ); |
| } |
| |
| /// Handler for [TextSelectionGestureDetector.onDragSelectionUpdate]. |
| /// |
| /// By default, it updates the selection location specified in the provided |
| /// details objects. |
| /// |
| /// See also: |
| /// |
| /// * [TextSelectionGestureDetector.onDragSelectionUpdate], which triggers |
| /// this callback./lib/src/material/text_field.dart |
| @protected |
| void onDragSelectionUpdate(DragStartDetails startDetails, DragUpdateDetails updateDetails) { |
| if (!delegate.selectionEnabled) |
| return; |
| 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, |
| required Widget child, |
| }) { |
| return TextSelectionGestureDetector( |
| key: key, |
| onTapDown: onTapDown, |
| onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, |
| onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, |
| onSecondaryTap: onSecondaryTap, |
| onSecondaryTapDown: onSecondaryTapDown, |
| 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.onSecondaryTap, |
| this.onSecondaryTapDown, |
| 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 [ForcePressGestureRecognizer.startPressure]. |
| final GestureForcePressStartCallback? onForcePressStart; |
| |
| /// Called when a pointer that had previously triggered [onForcePressStart] is |
| /// lifted off the screen. |
| final GestureForcePressEndCallback? onForcePressEnd; |
| |
| /// Called for a tap event with the secondary mouse button. |
| final GestureTapCallback? onSecondaryTap; |
| |
| /// Called for a tap down event with the secondary mouse button. |
| final GestureTapDownCallback? onSecondaryTapDown; |
| |
| /// Called for each distinct tap except for every second tap of a double tap. |
| /// For example, if the detector was configured with [onTapDown] 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 |
| ..onSecondaryTap = widget.onSecondaryTap |
| ..onSecondaryTapDown = widget.onSecondaryTapDown |
| ..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); |
| } |
| } |
| } |
| |
| /// A [ValueNotifier] whose [value] indicates whether the current contents of |
| /// the clipboard can be pasted. |
| /// |
| /// The contents of the clipboard can only be read asynchronously, via |
| /// [Clipboard.getData], so this maintains a value that can be used |
| /// synchronously. Call [update] to asynchronously update value if needed. |
| class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with WidgetsBindingObserver { |
| /// Create a new ClipboardStatusNotifier. |
| ClipboardStatusNotifier({ |
| ClipboardStatus value = ClipboardStatus.unknown, |
| }) : super(value); |
| |
| bool _disposed = false; |
| /// True iff this instance has been disposed. |
| bool get disposed => _disposed; |
| |
| /// Check the [Clipboard] and update [value] if needed. |
| Future<void> update() async { |
| // iOS 14 added a notification that appears when an app accesses the |
| // clipboard. To avoid the notification, don't access the clipboard on iOS, |
| // and instead always show the paste button, even when the clipboard is |
| // empty. |
| // TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that |
| // won't trigger the notification. |
| // https://github.com/flutter/flutter/issues/60145 |
| switch (defaultTargetPlatform) { |
| case TargetPlatform.iOS: |
| value = ClipboardStatus.pasteable; |
| return; |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.macOS: |
| case TargetPlatform.windows: |
| break; |
| } |
| |
| ClipboardData? data; |
| try { |
| data = await Clipboard.getData(Clipboard.kTextPlain); |
| } catch (stacktrace) { |
| // In the case of an error from the Clipboard API, set the value to |
| // unknown so that it will try to update again later. |
| if (_disposed || value == ClipboardStatus.unknown) { |
| return; |
| } |
| value = ClipboardStatus.unknown; |
| return; |
| } |
| |
| final ClipboardStatus clipboardStatus = data != null && data.text != null && data.text!.isNotEmpty |
| ? ClipboardStatus.pasteable |
| : ClipboardStatus.notPasteable; |
| if (_disposed || clipboardStatus == value) { |
| return; |
| } |
| value = clipboardStatus; |
| } |
| |
| @override |
| void addListener(VoidCallback listener) { |
| if (!hasListeners) { |
| WidgetsBinding.instance!.addObserver(this); |
| } |
| if (value == ClipboardStatus.unknown) { |
| update(); |
| } |
| super.addListener(listener); |
| } |
| |
| @override |
| void removeListener(VoidCallback listener) { |
| super.removeListener(listener); |
| if (!hasListeners) { |
| WidgetsBinding.instance!.removeObserver(this); |
| } |
| } |
| |
| @override |
| void didChangeAppLifecycleState(AppLifecycleState state) { |
| switch (state) { |
| case AppLifecycleState.resumed: |
| update(); |
| break; |
| case AppLifecycleState.detached: |
| case AppLifecycleState.inactive: |
| case AppLifecycleState.paused: |
| // Nothing to do. |
| } |
| } |
| |
| @override |
| void dispose() { |
| super.dispose(); |
| WidgetsBinding.instance!.removeObserver(this); |
| _disposed = true; |
| } |
| } |
| |
| /// An enumeration of the status of the content on the user's clipboard. |
| enum ClipboardStatus { |
| /// The clipboard content can be pasted, such as a String of nonzero length. |
| pasteable, |
| |
| /// The status of the clipboard is unknown. Since getting clipboard data is |
| /// asynchronous (see [Clipboard.getData]), this status often exists while |
| /// waiting to receive the clipboard contents for the first time. |
| unknown, |
| |
| /// The content on the clipboard is not pasteable, such as when it is empty. |
| notPasteable, |
| } |