blob: e751b6f88a07290712fa62e3758940ad2638e066 [file] [log] [blame]
// 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:characters/characters.dart';
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 'debug.dart';
import 'editable_text.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'magnifier.dart';
import 'overlay.dart';
import 'tap_region.dart';
import 'ticker_provider.dart';
import 'transitions.dart';
export 'package:flutter/rendering.dart' show TextSelectionPoint;
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);
/// 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.
///
/// See also:
///
/// * [SelectionArea], which selects appropriate text selection controls
/// based on the current platform.
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.
///
/// The supplied [onTap] should be invoked when the handle is tapped, if such
/// interaction is allowed. As a counterexample, the default selection handle
/// on iOS [cupertinoTextSelectionControls] does not call [onTap] at all,
/// since its handles are not meant to be tapped.
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]);
/// 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.
///
/// The [globalEditableRegion] parameter is the TextField size of the global
/// coordinate system in logical pixels.
///
/// The [textLineHeight] parameter is the [RenderEditable.preferredLineHeight]
/// of the [RenderEditable] we are building a toolbar for.
///
/// The [selectionMidpoint] parameter is a general calculation midpoint
/// parameter of the toolbar. More detailed position information
/// is computable from the [endpoints] parameter.
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
// TODO(chunhtai): Change to ValueListenable<ClipboardStatus>? once
// migration is done. https://github.com/flutter/flutter/issues/99360
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;
}
/// Call [TextSelectionDelegate.cutSelection] to cut current selection.
///
/// This is called by subclasses when their cut affordance is activated by
/// the user.
// TODO(chunhtai): remove optional parameter once migration is done.
// https://github.com/flutter/flutter/issues/99360
void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
delegate.cutSelection(SelectionChangedCause.toolbar);
}
/// Call [TextSelectionDelegate.copySelection] to copy current selection.
///
/// This is called by subclasses when their copy affordance is activated by
/// the user.
// TODO(chunhtai): remove optional parameter once migration is done.
// https://github.com/flutter/flutter/issues/99360
void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
delegate.copySelection(SelectionChangedCause.toolbar);
}
/// Call [TextSelectionDelegate.pasteText] to paste text.
///
/// 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 {
delegate.pasteText(SelectionChangedCause.toolbar);
}
/// Call [TextSelectionDelegate.selectAll] to set the current selection to
/// contain the entire text value.
///
/// 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.selectAll(SelectionChangedCause.toolbar);
}
}
/// Text selection controls that do not show any toolbars or handles.
///
/// This is a placeholder, suitable for temporary use during development, but
/// not practical for production. For example, it provides no way for the user
/// to interact with selections: no context menus on desktop, no toolbars or
/// drag handles on mobile, etc. For production, consider using
/// [MaterialTextSelectionControls] or creating a custom subclass of
/// [TextSelectionControls].
///
/// The [emptyTextSelectionControls] global variable has a
/// suitable instance of this class.
class EmptyTextSelectionControls extends TextSelectionControls {
@override
Size getHandleSize(double textLineHeight) => Size.zero;
@override
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ValueListenable<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) => const SizedBox.shrink();
@override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
return const SizedBox.shrink();
}
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) {
return Offset.zero;
}
}
/// Text selection controls that do not show any toolbars or handles.
///
/// This is a placeholder, suitable for temporary use during development, but
/// not practical for production. For example, it provides no way for the user
/// to interact with selections: no context menus on desktop, no toolbars or
/// drag handles on mobile, etc. For production, consider using
/// [materialTextSelectionControls] or creating a custom subclass of
/// [TextSelectionControls].
final TextSelectionControls emptyTextSelectionControls = EmptyTextSelectionControls();
/// An object that manages a pair of text selection handles for a
/// [RenderEditable].
///
/// This class is a wrapper of [SelectionOverlay] to provide APIs specific for
/// [RenderEditable]s. To manage selection handles for custom widgets, use
/// [SelectionOverlay] instead.
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,
Widget? debugRequiredFor,
required LayerLink toolbarLayerLink,
required LayerLink startHandleLayerLink,
required LayerLink endHandleLayerLink,
required this.renderObject,
this.selectionControls,
bool handlesVisible = false,
required this.selectionDelegate,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
VoidCallback? onSelectionHandleTapped,
ClipboardStatusNotifier? clipboardStatus,
required TextMagnifierConfiguration magnifierConfiguration,
}) : assert(value != null),
assert(context != null),
assert(handlesVisible != null),
_handlesVisible = handlesVisible,
_value = value {
renderObject.selectionStartInViewport.addListener(_updateTextSelectionOverlayVisibilities);
renderObject.selectionEndInViewport.addListener(_updateTextSelectionOverlayVisibilities);
_updateTextSelectionOverlayVisibilities();
_selectionOverlay = SelectionOverlay(
magnifierConfiguration: magnifierConfiguration,
context: context,
debugRequiredFor: debugRequiredFor,
// The metrics will be set when show handles.
startHandleType: TextSelectionHandleType.collapsed,
startHandlesVisible: _effectiveStartHandleVisibility,
lineHeightAtStart: 0.0,
onStartHandleDragStart: _handleSelectionStartHandleDragStart,
onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate,
onEndHandleDragEnd: _handleAnyDragEnd,
endHandleType: TextSelectionHandleType.collapsed,
endHandlesVisible: _effectiveEndHandleVisibility,
lineHeightAtEnd: 0.0,
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
onStartHandleDragEnd: _handleAnyDragEnd,
toolbarVisible: _effectiveToolbarVisibility,
selectionEndpoints: const <TextSelectionPoint>[],
selectionControls: selectionControls,
selectionDelegate: selectionDelegate,
clipboardStatus: clipboardStatus,
startHandleLayerLink: startHandleLayerLink,
endHandleLayerLink: endHandleLayerLink,
toolbarLayerLink: toolbarLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped,
dragStartBehavior: dragStartBehavior,
toolbarLocation: renderObject.lastSecondaryTapDownPosition,
);
}
/// Controls the fade-in and fade-out animations for the toolbar and handles.
@Deprecated(
'Use `SelectionOverlay.fadeDuration` instead. '
'This feature was deprecated after v2.12.0-4.1.pre.'
)
static const Duration fadeDuration = SelectionOverlay.fadeDuration;
// 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;
/// {@macro flutter.widgets.SelectionOverlay.selectionControls}
final TextSelectionControls? selectionControls;
/// {@macro flutter.widgets.SelectionOverlay.selectionDelegate}
final TextSelectionDelegate selectionDelegate;
late final SelectionOverlay _selectionOverlay;
/// Retrieve current value.
@visibleForTesting
TextEditingValue get value => _value;
TextEditingValue _value;
TextSelection get _selection => _value.selection;
final ValueNotifier<bool> _effectiveStartHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
/// 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;
void _updateTextSelectionOverlayVisibilities() {
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
_effectiveToolbarVisibility.value = renderObject.selectionStartInViewport.value || renderObject.selectionEndInViewport.value;
}
/// 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.
///
/// Defaults to false.
bool get handlesVisible => _handlesVisible;
bool _handlesVisible = false;
set handlesVisible(bool visible) {
assert(visible != null);
if (_handlesVisible == visible) {
return;
}
_handlesVisible = visible;
_updateTextSelectionOverlayVisibilities();
}
/// {@macro flutter.widgets.SelectionOverlay.showHandles}
void showHandles() {
_updateSelectionOverlay();
_selectionOverlay.showHandles();
}
/// {@macro flutter.widgets.SelectionOverlay.hideHandles}
void hideHandles() => _selectionOverlay.hideHandles();
/// {@macro flutter.widgets.SelectionOverlay.showToolbar}
void showToolbar() {
_updateSelectionOverlay();
_selectionOverlay.showToolbar();
}
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
void showMagnifier(Offset positionToShow) {
final TextPosition position = renderObject.getPositionForPoint(positionToShow);
_updateSelectionOverlay();
_selectionOverlay.showMagnifier(
_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: positionToShow,
renderEditable: renderObject,
),
);
}
/// {@macro flutter.widgets.SelectionOverlay.updateMagnifier}
void updateMagnifier(Offset positionToShow) {
final TextPosition position = renderObject.getPositionForPoint(positionToShow);
_updateSelectionOverlay();
_selectionOverlay.updateMagnifier(
_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: positionToShow,
renderEditable: renderObject,
),
);
}
/// {@macro flutter.widgets.SelectionOverlay.hideMagnifier}
void hideMagnifier({required bool shouldShowToolbar}) {
_selectionOverlay.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
}
/// 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;
_updateSelectionOverlay();
}
void _updateSelectionOverlay() {
_selectionOverlay
// Update selection handle metrics.
..startHandleType = _chooseType(
renderObject.textDirection,
TextSelectionHandleType.left,
TextSelectionHandleType.right,
)
..lineHeightAtStart = _getStartGlyphHeight()
..endHandleType = _chooseType(
renderObject.textDirection,
TextSelectionHandleType.right,
TextSelectionHandleType.left,
)
..lineHeightAtEnd = _getEndGlyphHeight()
// Update selection toolbar metrics.
..selectionEndpoints = renderObject.getEndpointsForSelection(_selection)
..toolbarLocation = renderObject.lastSecondaryTapDownPosition;
}
/// 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() => _updateSelectionOverlay();
/// Whether the handles are currently visible.
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
/// Whether the toolbar is currently visible.
bool get toolbarIsVisible => _selectionOverlay._toolbar != null;
/// Whether the magnifier is currently visible.
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
/// {@macro flutter.widgets.SelectionOverlay.hide}
void hide() => _selectionOverlay.hide();
/// {@macro flutter.widgets.SelectionOverlay.hideToolbar}
void hideToolbar() => _selectionOverlay.hideToolbar();
/// {@macro flutter.widgets.SelectionOverlay.dispose}
void dispose() {
_selectionOverlay.dispose();
renderObject.selectionStartInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
renderObject.selectionEndInViewport.removeListener(_updateTextSelectionOverlayVisibilities);
_effectiveToolbarVisibility.dispose();
_effectiveStartHandleVisibility.dispose();
_effectiveEndHandleVisibility.dispose();
}
double _getStartGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int firstSelectedGraphemeExtent;
Rect? startHandleRect;
// Only calculate handle rects if the text in the previous frame
// is the same as the text in the current frame. This is done because
// widget.renderObject contains the renderEditable from the previous frame.
// If the text changed between the current and previous frames then
// widget.renderObject.getRectForComposingRange might fail. In cases where
// the current frame is different from the previous we fall back to
// renderObject.preferredLineHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
startHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.start, end: _selection.start + firstSelectedGraphemeExtent));
}
return startHandleRect?.height ?? renderObject.preferredLineHeight;
}
double _getEndGlyphHeight() {
final InlineSpan span = renderObject.text!;
final String prevText = span.toPlainText();
final String currText = selectionDelegate.textEditingValue.text;
final int lastSelectedGraphemeExtent;
Rect? endHandleRect;
// See the explanation in _getStartGlyphHeight.
if (prevText == currText && _selection != null && _selection.isValid && !_selection.isCollapsed) {
final String selectedGraphemes = _selection.textInside(currText);
lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
endHandleRect = renderObject.getRectForComposingRange(TextRange(start: _selection.end - lastSelectedGraphemeExtent, end: _selection.end));
}
return endHandleRect?.height ?? renderObject.preferredLineHeight;
}
MagnifierInfo _buildMagnifier({
required RenderEditable renderEditable,
required Offset globalGesturePosition,
required TextPosition currentTextPosition,
}) {
final Offset globalRenderEditableTopLeft = renderEditable.localToGlobal(Offset.zero);
final Rect localCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition);
final TextSelection lineAtOffset = renderEditable.getLineAtOffset(currentTextPosition);
final TextPosition positionAtEndOfLine = TextPosition(
offset: lineAtOffset.extentOffset,
affinity: TextAffinity.upstream,
);
// Default affinity is downstream.
final TextPosition positionAtBeginningOfLine = TextPosition(
offset: lineAtOffset.baseOffset,
);
final Rect lineBoundaries = Rect.fromPoints(
renderEditable.getLocalRectForCaret(positionAtBeginningOfLine).topCenter,
renderEditable.getLocalRectForCaret(positionAtEndOfLine).bottomCenter,
);
return MagnifierInfo(
fieldBounds: globalRenderEditableTopLeft & renderEditable.size,
globalGesturePosition: globalGesturePosition,
caretRect: localCaretRect.shift(globalRenderEditableTopLeft),
currentLineBoundaries: lineBoundaries.shift(globalRenderEditableTopLeft),
);
}
late Offset _dragEndPosition;
void _handleSelectionEndHandleDragStart(DragStartDetails details) {
if (!renderObject.attached) {
return;
}
final Size handleSize = selectionControls!.getHandleSize(
renderObject.preferredLineHeight,
);
_dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height);
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
_selectionOverlay.showMagnifier(
_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
),
);
}
void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) {
if (!renderObject.attached) {
return;
}
_dragEndPosition += details.delta;
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
final TextSelection currentSelection = TextSelection.fromPosition(position);
if (_selection.isCollapsed) {
_selectionOverlay.updateMagnifier(_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
_handleSelectionHandleChanged(currentSelection, isEnd: true);
return;
}
final TextSelection newSelection;
switch (defaultTargetPlatform) {
// On Apple platforms, dragging the base handle makes it the extent.
case TargetPlatform.iOS:
case TargetPlatform.macOS:
newSelection = TextSelection(
extentOffset: position.offset,
baseOffset: _selection.start,
);
if (position.offset <= _selection.start) {
return; // Don't allow order swapping.
}
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
newSelection = TextSelection(
baseOffset: _selection.baseOffset,
extentOffset: position.offset,
);
if (newSelection.baseOffset >= newSelection.extentOffset) {
return; // Don't allow order swapping.
}
break;
}
_handleSelectionHandleChanged(newSelection, isEnd: true);
_selectionOverlay.updateMagnifier(_buildMagnifier(
currentTextPosition: newSelection.extent,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
}
late Offset _dragStartPosition;
void _handleSelectionStartHandleDragStart(DragStartDetails details) {
if (!renderObject.attached) {
return;
}
final Size handleSize = selectionControls!.getHandleSize(
renderObject.preferredLineHeight,
);
_dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height);
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
_selectionOverlay.showMagnifier(
_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
),
);
}
void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) {
if (!renderObject.attached) {
return;
}
_dragStartPosition += details.delta;
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
if (_selection.isCollapsed) {
_selectionOverlay.updateMagnifier(_buildMagnifier(
currentTextPosition: position,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
_handleSelectionHandleChanged(TextSelection.fromPosition(position), isEnd: false);
return;
}
final TextSelection newSelection;
switch (defaultTargetPlatform) {
// On Apple platforms, dragging the base handle makes it the extent.
case TargetPlatform.iOS:
case TargetPlatform.macOS:
newSelection = TextSelection(
extentOffset: position.offset,
baseOffset: _selection.end,
);
if (newSelection.extentOffset >= _selection.end) {
return; // Don't allow order swapping.
}
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
newSelection = TextSelection(
baseOffset: position.offset,
extentOffset: _selection.extentOffset,
);
if (newSelection.baseOffset >= newSelection.extentOffset) {
return; // Don't allow order swapping.
}
break;
}
_selectionOverlay.updateMagnifier(_buildMagnifier(
currentTextPosition: newSelection.extent.offset < newSelection.base.offset ? newSelection.extent : newSelection.base,
globalGesturePosition: details.globalPosition,
renderEditable: renderObject,
));
_handleSelectionHandleChanged(newSelection, isEnd: false);
}
void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed);
void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) {
final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base;
selectionDelegate.userUpdateTextEditingValue(
_value.copyWith(selection: newSelection),
SelectionChangedCause.drag,
);
selectionDelegate.bringIntoView(textPosition);
}
TextSelectionHandleType _chooseType(
TextDirection textDirection,
TextSelectionHandleType ltrType,
TextSelectionHandleType rtlType,
) {
if (_selection.isCollapsed) {
return TextSelectionHandleType.collapsed;
}
assert(textDirection != null);
switch (textDirection) {
case TextDirection.ltr:
return ltrType;
case TextDirection.rtl:
return rtlType;
}
}
}
/// An object that manages a pair of selection handles and a toolbar.
///
/// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext].
class SelectionOverlay {
/// Creates an object that manages overlay entries for selection handles.
///
/// The [context] must not be null and must have an [Overlay] as an ancestor.
SelectionOverlay({
required this.context,
this.debugRequiredFor,
required TextSelectionHandleType startHandleType,
required double lineHeightAtStart,
this.startHandlesVisible,
this.onStartHandleDragStart,
this.onStartHandleDragUpdate,
this.onStartHandleDragEnd,
required TextSelectionHandleType endHandleType,
required double lineHeightAtEnd,
this.endHandlesVisible,
this.onEndHandleDragStart,
this.onEndHandleDragUpdate,
this.onEndHandleDragEnd,
this.toolbarVisible,
required List<TextSelectionPoint> selectionEndpoints,
required this.selectionControls,
required this.selectionDelegate,
required this.clipboardStatus,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.toolbarLayerLink,
this.dragStartBehavior = DragStartBehavior.start,
this.onSelectionHandleTapped,
Offset? toolbarLocation,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : _startHandleType = startHandleType,
_lineHeightAtStart = lineHeightAtStart,
_endHandleType = endHandleType,
_lineHeightAtEnd = lineHeightAtEnd,
_selectionEndpoints = selectionEndpoints,
_toolbarLocation = toolbarLocation,
assert(debugCheckHasOverlay(context));
/// 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;
final ValueNotifier<MagnifierInfo> _magnifierInfo =
ValueNotifier<MagnifierInfo>(MagnifierInfo.empty);
/// [MagnifierController.show] and [MagnifierController.hide] should not be called directly, except
/// from inside [showMagnifier] and [hideMagnifier]. If it is desired to show or hide the magnifier,
/// call [showMagnifier] or [hideMagnifier]. This is because the magnifier needs to orchestrate
/// with other properties in [SelectionOverlay].
final MagnifierController _magnifierController = MagnifierController();
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
///
/// By default, [SelectionOverlay]'s [TextMagnifierConfiguration] is disabled.
///
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.details}
final TextMagnifierConfiguration magnifierConfiguration;
/// {@template flutter.widgets.SelectionOverlay.showMagnifier}
/// Shows the magnifier, and hides the toolbar if it was showing when [showMagnifier]
/// was called. This is safe to call on platforms not mobile, since
/// a magnifierBuilder will not be provided, or the magnifierBuilder will return null
/// on platforms not mobile.
///
/// This is NOT the source of truth for if the magnifier is up or not,
/// since magnifiers may hide themselves. If this info is needed, check
/// [MagnifierController.shown].
/// {@endtemplate}
void showMagnifier(MagnifierInfo initalMagnifierInfo) {
if (_toolbar != null) {
hideToolbar();
}
// Start from empty, so we don't utilize any rememnant values.
_magnifierInfo.value = initalMagnifierInfo;
// Pre-build the magnifiers so we can tell if we've built something
// or not. If we don't build a magnifiers, then we should not
// insert anything in the overlay.
final Widget? builtMagnifier = magnifierConfiguration.magnifierBuilder(
context,
_magnifierController,
_magnifierInfo,
);
if (builtMagnifier == null) {
return;
}
_magnifierController.show(
context: context,
below: magnifierConfiguration.shouldDisplayHandlesInMagnifier
? null
: _handles?.first,
builder: (_) => builtMagnifier);
}
/// {@template flutter.widgets.SelectionOverlay.hideMagnifier}
/// Hide the current magnifier, optionally immediately showing
/// the toolbar.
///
/// This does nothing if there is no magnifier.
/// {@endtemplate}
void hideMagnifier({required bool shouldShowToolbar}) {
// This cannot be a check on `MagnifierController.shown`, since
// it's possible that the magnifier is still in the overlay, but
// not shown in cases where the magnifier hides itself.
if (_magnifierController.overlayEntry == null) {
return;
}
_magnifierController.hide();
if (shouldShowToolbar) {
showToolbar();
}
}
/// The type of start selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
TextSelectionHandleType get startHandleType => _startHandleType;
TextSelectionHandleType _startHandleType;
set startHandleType(TextSelectionHandleType value) {
if (_startHandleType == value) {
return;
}
_startHandleType = value;
_markNeedsBuild();
}
/// The line height at the selection start.
///
/// This value is used for calculating the size of the start selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
double get lineHeightAtStart => _lineHeightAtStart;
double _lineHeightAtStart;
set lineHeightAtStart(double value) {
if (_lineHeightAtStart == value) {
return;
}
_lineHeightAtStart = value;
_markNeedsBuild();
}
/// Whether the start handle is visible.
///
/// If the value changes, the start handle uses [FadeTransition] to transition
/// itself on and off the screen.
///
/// If this is null, the start selection handle will always be visible.
final ValueListenable<bool>? startHandlesVisible;
/// Called when the users start dragging the start selection handles.
final ValueChanged<DragStartDetails>? onStartHandleDragStart;
/// Called when the users drag the start selection handles to new locations.
final ValueChanged<DragUpdateDetails>? onStartHandleDragUpdate;
/// Called when the users lift their fingers after dragging the start selection
/// handles.
final ValueChanged<DragEndDetails>? onStartHandleDragEnd;
/// The type of end selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
TextSelectionHandleType get endHandleType => _endHandleType;
TextSelectionHandleType _endHandleType;
set endHandleType(TextSelectionHandleType value) {
if (_endHandleType == value) {
return;
}
_endHandleType = value;
_markNeedsBuild();
}
/// The line height at the selection end.
///
/// This value is used for calculating the size of the end selection handle.
///
/// Changing the value while the handles are visible causes them to rebuild.
double get lineHeightAtEnd => _lineHeightAtEnd;
double _lineHeightAtEnd;
set lineHeightAtEnd(double value) {
if (_lineHeightAtEnd == value) {
return;
}
_lineHeightAtEnd = value;
_markNeedsBuild();
}
/// Whether the end handle is visible.
///
/// If the value changes, the end handle uses [FadeTransition] to transition
/// itself on and off the screen.
///
/// If this is null, the end selection handle will always be visible.
final ValueListenable<bool>? endHandlesVisible;
/// Called when the users start dragging the end selection handles.
final ValueChanged<DragStartDetails>? onEndHandleDragStart;
/// Called when the users drag the end selection handles to new locations.
final ValueChanged<DragUpdateDetails>? onEndHandleDragUpdate;
/// Called when the users lift their fingers after dragging the end selection
/// handles.
final ValueChanged<DragEndDetails>? onEndHandleDragEnd;
/// Whether the toolbar is visible.
///
/// If the value changes, the toolbar uses [FadeTransition] to transition
/// itself on and off the screen.
///
/// If this is null the toolbar will always be visible.
final ValueListenable<bool>? toolbarVisible;
/// The text selection positions of selection start and end.
List<TextSelectionPoint> get selectionEndpoints => _selectionEndpoints;
List<TextSelectionPoint> _selectionEndpoints;
set selectionEndpoints(List<TextSelectionPoint> value) {
if (!listEquals(_selectionEndpoints, value)) {
_markNeedsBuild();
}
_selectionEndpoints = value;
}
/// 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;
/// {@template flutter.widgets.SelectionOverlay.selectionControls}
/// Builds text selection handles and toolbar.
/// {@endtemplate}
final TextSelectionControls? selectionControls;
/// {@template flutter.widgets.SelectionOverlay.selectionDelegate}
/// The delegate for manipulating the current selection in the owning
/// text field.
/// {@endtemplate}
final TextSelectionDelegate selectionDelegate;
/// Determines the way that drag start behavior is handled.
///
/// If set to [DragStartBehavior.start], handle drag behavior will
/// begin at the position where the drag gesture won the arena. If set to
/// [DragStartBehavior.down] it will begin at the position where 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.SelectionOverlay.onSelectionHandleTapped}
/// A callback that's optionally invoked when a selection handle is tapped.
///
/// The [TextSelectionControls.buildHandle] implementation the text field
/// uses decides where the handle's tap "hotspot" is, or whether the
/// selection handle supports tap gestures at all. For instance,
/// [MaterialTextSelectionControls] calls [onSelectionHandleTapped] when the
/// selection handle's "knob" is tapped, while
/// [CupertinoTextSelectionControls] builds a handle that's not sufficiently
/// large for tapping (as it's not meant to be tapped) so it does not call
/// [onSelectionHandleTapped] even when tapped.
/// {@endtemplate}
// See https://github.com/flutter/flutter/issues/39376#issuecomment-848406415
// for provenance.
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;
/// The location of where the toolbar should be drawn in relative to the
/// location of [toolbarLayerLink].
///
/// If this is null, the toolbar is drawn based on [selectionEndpoints] and
/// the rect of render object of [context].
///
/// This is useful for displaying toolbars at the mouse right-click locations
/// in desktop devices.
Offset? get toolbarLocation => _toolbarLocation;
Offset? _toolbarLocation;
set toolbarLocation(Offset? value) {
if (_toolbarLocation == value) {
return;
}
_toolbarLocation = value;
_markNeedsBuild();
}
/// Controls the fade-in and fade-out animations for the toolbar and handles.
static const Duration fadeDuration = Duration(milliseconds: 150);
/// 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;
/// {@template flutter.widgets.SelectionOverlay.showHandles}
/// Builds the handles by inserting them into the [context]'s overlay.
/// {@endtemplate}
void showHandles() {
if (_handles != null) {
return;
}
_handles = <OverlayEntry>[
OverlayEntry(builder: _buildStartHandle),
OverlayEntry(builder: _buildEndHandle),
];
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insertAll(_handles!);
}
/// {@template flutter.widgets.SelectionOverlay.hideHandles}
/// Destroys the handles by removing them from overlay.
/// {@endtemplate}
void hideHandles() {
if (_handles != null) {
_handles![0].remove();
_handles![1].remove();
_handles = null;
}
}
/// {@template flutter.widgets.SelectionOverlay.showToolbar}
/// Shows the toolbar by inserting it into the [context]'s overlay.
/// {@endtemplate}
void showToolbar() {
if (_toolbar != null) {
return;
}
_toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insert(_toolbar!);
}
bool _buildScheduled = false;
void _markNeedsBuild() {
if (_handles == null && _toolbar == null) {
return;
}
// 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) {
if (_buildScheduled) {
return;
}
_buildScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
_buildScheduled = false;
if (_handles != null) {
_handles![0].markNeedsBuild();
_handles![1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
});
} else {
if (_handles != null) {
_handles![0].markNeedsBuild();
_handles![1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
}
}
/// {@template flutter.widgets.SelectionOverlay.hide}
/// Hides the entire overlay including the toolbar and the handles.
/// {@endtemplate}
void hide() {
_magnifierController.hide();
if (_handles != null) {
_handles![0].remove();
_handles![1].remove();
_handles = null;
}
if (_toolbar != null) {
hideToolbar();
}
}
/// {@template flutter.widgets.SelectionOverlay.hideToolbar}
/// Hides the toolbar part of the overlay.
///
/// To hide the whole overlay, see [hide].
/// {@endtemplate}
void hideToolbar() {
if (_toolbar == null) {
return;
}
_toolbar?.remove();
_toolbar = null;
}
/// {@template flutter.widgets.SelectionOverlay.dispose}
/// Disposes this object and release resources.
/// {@endtemplate}
void dispose() {
hide();
}
Widget _buildStartHandle(BuildContext context) {
final Widget handle;
final TextSelectionControls? selectionControls = this.selectionControls;
if (selectionControls == null) {
handle = const SizedBox.shrink();
} else {
handle = _SelectionHandleOverlay(
type: _startHandleType,
handleLayerLink: startHandleLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped,
onSelectionHandleDragStart: onStartHandleDragStart,
onSelectionHandleDragUpdate: onStartHandleDragUpdate,
onSelectionHandleDragEnd: onStartHandleDragEnd,
selectionControls: selectionControls,
visibility: startHandlesVisible,
preferredLineHeight: _lineHeightAtStart,
dragStartBehavior: dragStartBehavior,
);
}
return TextFieldTapRegion(
child: ExcludeSemantics(
child: handle,
),
);
}
Widget _buildEndHandle(BuildContext context) {
final Widget handle;
final TextSelectionControls? selectionControls = this.selectionControls;
if (selectionControls == null || _startHandleType == TextSelectionHandleType.collapsed) {
// Hide the second handle when collapsed.
handle = const SizedBox.shrink();
} else {
handle = _SelectionHandleOverlay(
type: _endHandleType,
handleLayerLink: endHandleLayerLink,
onSelectionHandleTapped: onSelectionHandleTapped,
onSelectionHandleDragStart: onEndHandleDragStart,
onSelectionHandleDragUpdate: onEndHandleDragUpdate,
onSelectionHandleDragEnd: onEndHandleDragEnd,
selectionControls: selectionControls,
visibility: endHandlesVisible,
preferredLineHeight: _lineHeightAtEnd,
dragStartBehavior: dragStartBehavior,
);
}
return TextFieldTapRegion(
child: ExcludeSemantics(
child: handle,
),
);
}
Widget _buildToolbar(BuildContext context) {
if (selectionControls == null) {
return const SizedBox.shrink();
}
final RenderBox renderBox = this.context.findRenderObject()! as RenderBox;
final Rect editingRegion = Rect.fromPoints(
renderBox.localToGlobal(Offset.zero),
renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
);
final bool isMultiline = selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy >
lineHeightAtEnd / 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
: (selectionEndpoints.first.point.dx + selectionEndpoints.last.point.dx) / 2;
final Offset midpoint = Offset(
midX,
// The y-coordinate won't be made use of most likely.
selectionEndpoints.first.point.dy - lineHeightAtStart,
);
return TextFieldTapRegion(
child: Directionality(
textDirection: Directionality.of(this.context),
child: _SelectionToolbarOverlay(
preferredLineHeight: lineHeightAtStart,
toolbarLocation: toolbarLocation,
layerLink: toolbarLayerLink,
editingRegion: editingRegion,
selectionControls: selectionControls,
midpoint: midpoint,
selectionEndpoints: selectionEndpoints,
visibility: toolbarVisible,
selectionDelegate: selectionDelegate,
clipboardStatus: clipboardStatus,
),
),
);
}
/// {@template flutter.widgets.SelectionOverlay.updateMagnifier}
/// Update the current magnifier with new selection data, so the magnifier
/// can respond accordingly.
///
/// If the magnifier is not shown, this still updates the magnifier position
/// because the magnifier may have hidden itself and is looking for a cue to reshow
/// itself.
///
/// If there is no magnifier in the overlay, this does nothing.
/// {@endtemplate}
void updateMagnifier(MagnifierInfo magnifierInfo) {
if (_magnifierController.overlayEntry == null) {
return;
}
_magnifierInfo.value = magnifierInfo;
}
}
/// This widget represents a selection toolbar.
class _SelectionToolbarOverlay extends StatefulWidget {
/// Creates a toolbar overlay.
const _SelectionToolbarOverlay({
required this.preferredLineHeight,
required this.toolbarLocation,
required this.layerLink,
required this.editingRegion,
required this.selectionControls,
this.visibility,
required this.midpoint,
required this.selectionEndpoints,
required this.selectionDelegate,
required this.clipboardStatus,
});
final double preferredLineHeight;
final Offset? toolbarLocation;
final LayerLink layerLink;
final Rect editingRegion;
final TextSelectionControls? selectionControls;
final ValueListenable<bool>? visibility;
final Offset midpoint;
final List<TextSelectionPoint> selectionEndpoints;
final TextSelectionDelegate? selectionDelegate;
final ClipboardStatusNotifier? clipboardStatus;
@override
_SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState();
}
class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
_toolbarVisibilityChanged();
widget.visibility?.addListener(_toolbarVisibilityChanged);
}
@override
void didUpdateWidget(_SelectionToolbarOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.visibility == widget.visibility) {
return;
}
oldWidget.visibility?.removeListener(_toolbarVisibilityChanged);
_toolbarVisibilityChanged();
widget.visibility?.addListener(_toolbarVisibilityChanged);
}
@override
void dispose() {
widget.visibility?.removeListener(_toolbarVisibilityChanged);
_controller.dispose();
super.dispose();
}
void _toolbarVisibilityChanged() {
if (widget.visibility?.value ?? true) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
offset: -widget.editingRegion.topLeft,
child: Builder(
builder: (BuildContext context) {
return widget.selectionControls!.buildToolbar(
context,
widget.editingRegion,
widget.preferredLineHeight,
widget.midpoint,
widget.selectionEndpoints,
widget.selectionDelegate!,
widget.clipboardStatus,
widget.toolbarLocation,
);
},
),
),
);
}
}
/// This widget represents a single draggable selection handle.
class _SelectionHandleOverlay extends StatefulWidget {
/// Create selection overlay.
const _SelectionHandleOverlay({
required this.type,
required this.handleLayerLink,
this.onSelectionHandleTapped,
this.onSelectionHandleDragStart,
this.onSelectionHandleDragUpdate,
this.onSelectionHandleDragEnd,
required this.selectionControls,
this.visibility,
required this.preferredLineHeight,
this.dragStartBehavior = DragStartBehavior.start,
});
final LayerLink handleLayerLink;
final VoidCallback? onSelectionHandleTapped;
final ValueChanged<DragStartDetails>? onSelectionHandleDragStart;
final ValueChanged<DragUpdateDetails>? onSelectionHandleDragUpdate;
final ValueChanged<DragEndDetails>? onSelectionHandleDragEnd;
final TextSelectionControls selectionControls;
final ValueListenable<bool>? visibility;
final double preferredLineHeight;
final TextSelectionHandleType type;
final DragStartBehavior dragStartBehavior;
@override
State<_SelectionHandleOverlay> createState() => _SelectionHandleOverlayState();
}
class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: SelectionOverlay.fadeDuration, vsync: this);
_handleVisibilityChanged();
widget.visibility?.addListener(_handleVisibilityChanged);
}
void _handleVisibilityChanged() {
if (widget.visibility?.value ?? true) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void didUpdateWidget(_SelectionHandleOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.visibility?.removeListener(_handleVisibilityChanged);
_handleVisibilityChanged();
widget.visibility?.addListener(_handleVisibilityChanged);
}
@override
void dispose() {
widget.visibility?.removeListener(_handleVisibilityChanged);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
widget.type,
widget.preferredLineHeight,
);
final Size handleSize = widget.selectionControls.getHandleSize(
widget.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: widget.handleLayerLink,
offset: interactiveRect.topLeft,
showWhenUnlinked: false,
child: FadeTransition(
opacity: _opacity,
child: Container(
alignment: Alignment.topLeft,
width: interactiveRect.width,
height: interactiveRect.height,
child: RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
PanGestureRecognizer: GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(
debugOwner: this,
// Mouse events select the text and do not drag the cursor.
supportedDevices: <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.unknown,
},
),
(PanGestureRecognizer instance) {
instance
..dragStartBehavior = widget.dragStartBehavior
..onStart = widget.onSelectionHandleDragStart
..onUpdate = widget.onSelectionHandleDragUpdate
..onEnd = widget.onSelectionHandleDragEnd;
},
),
},
child: Padding(
padding: EdgeInsets.only(
left: padding.left,
top: padding.top,
right: padding.right,
bottom: padding.bottom,
),
child: widget.selectionControls.buildHandle(
context,
widget.type,
widget.preferredLineHeight,
widget.onSelectionHandleTapped,
),
),
),
),
),
);
}
}
/// Delegate interface for the [TextSelectionGestureDetectorBuilder].
///
/// The interface is usually implemented by text field 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 text field.
/// 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 text field.
/// * [CupertinoTextField], which implements this delegate for the Cupertino
/// text field.
abstract class TextSelectionGestureDetectorBuilderDelegate {
/// [GlobalKey] to the [EditableText] for which the
/// [TextSelectionGestureDetectorBuilder] will build a [TextSelectionGestureDetector].
GlobalKey<EditableTextState> get editableTextKey;
/// Whether the text field should respond to force presses.
bool get forcePressEnabled;
/// Whether the user may select text in the text field.
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 text field. Based on this, the builder adds
/// the correct gesture handlers to the gesture detector.
@protected
final TextSelectionGestureDetectorBuilderDelegate delegate;
/// Returns true if 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!.start <= textPosition.offset
&& renderEditable.selection!.end >= textPosition.offset;
}
// Expand the selection to the given global position.
//
// Either base or extent will be moved to the last tapped position, whichever
// is closest. The selection will never shrink or pivot, only grow.
//
// If fromSelection is given, will expand from that selection instead of the
// current selection in renderEditable.
//
// See also:
//
// * [_extendSelection], which is similar but pivots the selection around
// the base.
void _expandSelection(Offset offset, SelectionChangedCause cause, [TextSelection? fromSelection]) {
assert(cause != null);
assert(offset != null);
assert(renderEditable.selection?.baseOffset != null);
final TextPosition tappedPosition = renderEditable.getPositionForPoint(offset);
final TextSelection selection = fromSelection ?? renderEditable.selection!;
final bool baseIsCloser =
(tappedPosition.offset - selection.baseOffset).abs()
< (tappedPosition.offset - selection.extentOffset).abs();
final TextSelection nextSelection = selection.copyWith(
baseOffset: baseIsCloser ? selection.extentOffset : selection.baseOffset,
extentOffset: tappedPosition.offset,
);
editableText.userUpdateTextEditingValue(
editableText.textEditingValue.copyWith(
selection: nextSelection,
),
cause,
);
}
// Extend the selection to the given global position.
//
// Holds the base in place and moves the extent.
//
// See also:
//
// * [_expandSelection], which is similar but always increases the size of
// the selection.
void _extendSelection(Offset offset, SelectionChangedCause cause) {
assert(cause != null);
assert(offset != null);
assert(renderEditable.selection?.baseOffset != null);
final TextPosition tappedPosition = renderEditable.getPositionForPoint(offset);
final TextSelection selection = renderEditable.selection!;
final TextSelection nextSelection = selection.copyWith(
extentOffset: tappedPosition.offset,
);
editableText.userUpdateTextEditingValue(
editableText.textEditingValue.copyWith(
selection: nextSelection,
),
cause,
);
}
/// 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;
// The viewport offset pixels of the [RenderEditable] at the last drag start.
double _dragStartViewportOffset = 0.0;
// Returns true iff either shift key is currently down.
bool get _isShiftPressed {
return HardwareKeyboard.instance.logicalKeysPressed
.any(<LogicalKeyboardKey>{
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
}.contains);
}
// True iff a tap + shift has been detected but the tap has not yet come up.
bool _isShiftTapping = false;
// For a shift + tap + drag gesture, the TextSelection at the point of the
// tap. Mac uses this value to reset to the original selection when an
// inversion of the base and offset happens.
TextSelection? _shiftTapDragSelection;
/// 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) {
if (!delegate.selectionEnabled) {
return;
}
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;
// Handle shift + click selection if needed.
final bool isShiftPressedValid = _isShiftPressed && renderEditable.selection?.baseOffset != null;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
// On mobile platforms the selection is set on tap up.
if (_isShiftTapping) {
_isShiftTapping = false;
}
break;
case TargetPlatform.macOS:
// On macOS, a shift-tapped unfocused field expands from 0, not from the
// previous selection.
if (isShiftPressedValid) {
_isShiftTapping = true;
final TextSelection? fromSelection = renderEditable.hasFocus
? null
: const TextSelection.collapsed(offset: 0);
_expandSelection(
details.globalPosition,
SelectionChangedCause.tap,
fromSelection,
);
return;
}
// On macOS, a tap/click places the selection in a precise position.
// This differs from iOS/iPadOS, where if the gesture is done by a touch
// then the selection moves to the closest word edge, instead of a
// precise position.
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
if (isShiftPressedValid) {
_isShiftTapping = true;
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
return;
}
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
}
}
/// 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) {
// Handle shift + click selection if needed.
final bool isShiftPressedValid = _isShiftPressed && renderEditable.selection?.baseOffset != null;
switch (defaultTargetPlatform) {
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
// On desktop platforms the selection is set on tap down.
if (_isShiftTapping) {
_isShiftTapping = false;
}
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
if (isShiftPressedValid) {
_isShiftTapping = true;
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
return;
}
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.iOS:
if (isShiftPressedValid) {
// On iOS, a shift-tapped unfocused field expands from 0, not from
// the previous selection.
_isShiftTapping = true;
final TextSelection? fromSelection = renderEditable.hasFocus
? null
: const TextSelection.collapsed(offset: 0);
_expandSelection(
details.globalPosition,
SelectionChangedCause.tap,
fromSelection,
);
return;
}
switch (details.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
// Precise devices should place the cursor at a precise position.
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
// On iOS/iPadOS a touch tap places the cursor at the edge of the word.
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
break;
}
break;
}
}
}
/// 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,
);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.showMagnifier(details.globalPosition);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
}
}
/// 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,
);
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.showMagnifier(details.globalPosition);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
}
}
/// Handler for [TextSelectionGestureDetector.onSingleLongTapEnd].
///
/// By default, it shows toolbar if necessary.
///
/// See also:
///
/// * [TextSelectionGestureDetector.onSingleLongTapEnd], which triggers this
/// callback.
@protected
void onSingleLongTapEnd(LongPressEndDetails details) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
editableText.hideMagnifier(shouldShowToolbar: false);
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}
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) {
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
if (!_lastSecondaryTapWasOnSelection || !renderEditable.hasFocus) {
renderEditable.selectWord(cause: SelectionChangedCause.tap);
}
if (shouldShowSelectionToolbar) {
editableText.hideToolbar();
editableText.showToolbar();
}
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
if (!renderEditable.hasFocus) {
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
}
editableText.toggleToolbar();
break;
}
}
/// 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;
if (_isShiftPressed && renderEditable.selection != null && renderEditable.selection!.isValid) {
_isShiftTapping = true;
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
_expandSelection(details.globalPosition, SelectionChangedCause.drag);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
_extendSelection(details.globalPosition, SelectionChangedCause.drag);
break;
}
_shiftTapDragSelection = renderEditable.selection;
} else {
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
}
_dragStartViewportOffset = renderEditable.offset.pixels;
}
/// 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;
}
if (!_isShiftTapping) {
// Adjust the drag start offset for possible viewport offset changes.
final Offset startOffset = renderEditable.maxLines == 1
? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0)
: Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset);
return renderEditable.selectPositionAt(
from: startDetails.globalPosition - startOffset,
to: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
}
if (_shiftTapDragSelection!.isCollapsed
|| (defaultTargetPlatform != TargetPlatform.iOS
&& defaultTargetPlatform != TargetPlatform.macOS)) {
return _extendSelection(updateDetails.globalPosition, SelectionChangedCause.drag);
}
// If the drag inverts the selection, Mac and iOS revert to the initial
// selection.
final TextSelection selection = editableText.textEditingValue.selection;
final TextPosition nextExtent = renderEditable.getPositionForPoint(updateDetails.globalPosition);
final bool isShiftTapDragSelectionForward =
_shiftTapDragSelection!.baseOffset < _shiftTapDragSelection!.extentOffset;
final bool isInverted = isShiftTapDragSelectionForward
? nextExtent.offset < _shiftTapDragSelection!.baseOffset
: nextExtent.offset > _shiftTapDragSelection!.baseOffset;
if (isInverted && selection.baseOffset == _shiftTapDragSelection!.baseOffset) {
editableText.userUpdateTextEditingValue(
editableText.textEditingValue.copyWith(
selection: TextSelection(
baseOffset: _shiftTapDragSelection!.extentOffset,
extentOffset: nextExtent.offset,
),
),
SelectionChangedCause.drag,
);
} else if (!isInverted
&& nextExtent.offset != _shiftTapDragSelection!.baseOffset
&& selection.baseOffset != _shiftTapDragSelection!.baseOffset) {
editableText.userUpdateTextEditingValue(
editableText.textEditingValue.copyWith(
selection: TextSelection(
baseOffset: _shiftTapDragSelection!.baseOffset,
extentOffset: nextExtent.offset,
),
),
SelectionChangedCause.drag,
);
} else {
_extendSelection(updateDetails.globalPosition, SelectionChangedCause.drag);
}
}
/// Handler for [TextSelectionGestureDetector.onDragSelectionEnd].
///
/// By default, it simply cleans up the state used for handling certain
/// built-in behaviors.
///
/// See also:
///
/// * [TextSelectionGestureDetector.onDragSelectionEnd], which triggers this
/// callback.
@protected
void onDragSelectionEnd(DragEndDetails details) {
if (_isShiftTapping) {
_isShiftTapping = false;
_shiftTapDragSelection = null;
}
}
/// 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({
super.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);
/// 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) {
widget.onTapDown?.call(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.
widget.onDoubleTapDown?.call(details);
_doubleTapTimer!.cancel();
_doubleTapTimeout();
_isDoubleTap = true;
}
}
void _handleTapUp(TapUpDetails details) {
if (!_isDoubleTap) {
widget.onSingleTapUp?.call(details);
_lastTapOffset = details.globalPosition;
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
}
_isDoubleTap = false;
}
void _handleTapCancel() {
widget.onSingleTapCancel?.call();
}
DragStartDetails? _lastDragStartDetails;
DragUpdateDetails? _lastDragUpdateDetails;
Timer? _dragUpdateThrottleTimer;
void _handleDragStart(DragStartDetails details) {
assert(_lastDragStartDetails == null);
_lastDragStartDetails = details;
widget.onDragSelectionStart?.call(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);
widget.onDragSelectionUpdate?.call(_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();
}
widget.onDragSelectionEnd?.call(details);
_dragUpdateThrottleTimer = null;
_lastDragStartDetails = null;
_lastDragUpdateDetails = null;
}
void _forcePressStarted(ForcePressDetails details) {
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
widget.onForcePressStart?.call(details);
}
void _forcePressEnded(ForcePressDetails details) {
widget.onForcePressEnd?.call(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>{};
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer 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) {
gestures[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(debugOwner: this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
(PanGestureRecognizer 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 [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;
// TODO(chunhtai): remove this getter once migration is done.
// https://github.com/flutter/flutter/issues/99360
/// True if this instance has been disposed.
bool get disposed => _disposed;
/// Check the [Clipboard] and update [value] if needed.
Future<void> update() async {
if (_disposed) {
return;
}
final bool hasStrings;
try {
hasStrings = await Clipboard.hasStrings();
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'widget library',
context: ErrorDescription('while checking if the clipboard has strings'),
));
// 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 nextStatus = hasStrings
? ClipboardStatus.pasteable
: ClipboardStatus.notPasteable;
if (_disposed || nextStatus == value) {
return;
}
value = nextStatus;
}
@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 (!_disposed && !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() {
WidgetsBinding.instance.removeObserver(this);
_disposed = true;
super.dispose();
}
}
/// 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 pastable, such as when it is empty.
notPasteable,
}