blob: 28096e08b55c1d3d6664fd20aee2cc1ec04bc9d5 [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'basic.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';
export 'package:flutter/services.dart' show TextSelectionDelegate;
/// A duration that controls how often the drag selection update callback is
/// called.
const Duration _kDragSelectionUpdateThrottle = Duration(milliseconds: 50);
/// Which type of selection handle to be displayed.
///
/// With mixed-direction text, both handles may be the same type. Examples:
///
/// * LTR text: 'the <quick brown> fox':
///
/// The '<' is drawn with the [left] type, the '>' with the [right]
///
/// * RTL text: 'XOF <NWORB KCIUQ> EHT':
///
/// Same as above.
///
/// * mixed text: '<the NWOR<B KCIUQ fox'
///
/// Here 'the QUICK B' is selected, but 'QUICK BROWN' is RTL. Both are drawn
/// with the [left] type.
///
/// See also:
///
/// * [TextDirection], which discusses left-to-right and right-to-left text in
/// more detail.
enum TextSelectionHandleType {
/// The selection handle is to the left of the selection end point.
left,
/// The selection handle is to the right of the selection end point.
right,
/// The start and end of the selection are co-incident at this point.
collapsed,
}
/// The text position that a give selection handle manipulates. Dragging the
/// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { start, end }
/// Signature for reporting changes to the selection component of a
/// [TextEditingValue] for the purposes of a [TextSelectionOverlay]. The
/// [caretRect] argument gives the location of the caret in the coordinate space
/// of the [RenderBox] given by the [TextSelectionOverlay.renderObject].
///
/// Used by [TextSelectionOverlay.onSelectionOverlayChanged].
typedef TextSelectionOverlayChanged = void Function(TextEditingValue value, Rect caretRect);
/// Signature for when a pointer that's dragging to select text has moved again.
///
/// The first argument [startDetails] contains the details of the event that
/// initiated the dragging.
///
/// The second argument [updateDetails] contains the details of the current
/// pointer movement. It's the same as the one passed to [DragGestureRecognizer.onUpdate].
///
/// This signature is different from [GestureDragUpdateCallback] to make it
/// easier for various text fields to use [TextSelectionGestureDetector] without
/// having to store the start position.
typedef DragSelectionUpdateCallback = void Function(DragStartDetails startDetails, DragUpdateDetails updateDetails);
/// An interface for building the selection UI, to be provided by the
/// implementor of the toolbar widget.
///
/// Override text operations such as [handleCut] if needed.
abstract class TextSelectionControls {
/// Builds a selection handle of the given type.
///
/// The top left corner of this widget is positioned at the bottom of the
/// selection position.
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight);
/// Builds a toolbar near a text selection.
///
/// Typically displays buttons for copying and pasting text.
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate);
/// Returns the size of the selection handle.
Size get handleSize;
/// Whether the current selection of the text field managed by the given
/// `delegate` can be removed from the text field and placed into the
/// [Clipboard].
///
/// By default, false is returned when nothing is selected in the text field.
///
/// Subclasses can use this to decide if they should expose the cut
/// functionality to the user.
bool canCut(TextSelectionDelegate delegate) {
return !delegate.textEditingValue.selection.isCollapsed;
}
/// Whether the current selection of the text field managed by the given
/// `delegate` can be copied to the [Clipboard].
///
/// By default, false is returned when nothing is selected in the text field.
///
/// Subclasses can use this to decide if they should expose the copy
/// functionality to the user.
bool canCopy(TextSelectionDelegate delegate) {
return !delegate.textEditingValue.selection.isCollapsed;
}
/// Whether the current [Clipboard] content can be pasted into the text field
/// managed by the given `delegate`.
///
/// Subclasses can use this to decide if they should expose the paste
/// functionality to the user.
bool canPaste(TextSelectionDelegate delegate) {
// TODO(goderbauer): return false when clipboard is empty, https://github.com/flutter/flutter/issues/11254
return true;
}
/// Whether the current selection of the text field managed by the given
/// `delegate` can be extended to include the entire content of the text
/// field.
///
/// Subclasses can use this to decide if they should expose the select all
/// functionality to the user.
bool canSelectAll(TextSelectionDelegate delegate) {
return delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed;
}
/// Copy the current selection of the text field managed by the given
/// `delegate` to the [Clipboard]. Then, remove the selected text from the
/// text field and hide the toolbar.
///
/// This is called by subclasses when their cut affordance is activated by
/// the user.
void handleCut(TextSelectionDelegate delegate) {
final TextEditingValue value = delegate.textEditingValue;
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
delegate.textEditingValue = TextEditingValue(
text: value.selection.textBefore(value.text)
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start
),
);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
}
/// Copy the current selection of the text field managed by the given
/// `delegate` to the [Clipboard]. Then, move the cursor to the end of the
/// text (collapsing the selection in the process), and hide the toolbar.
///
/// This is called by subclasses when their copy affordance is activated by
/// the user.
void handleCopy(TextSelectionDelegate delegate) {
final TextEditingValue value = delegate.textEditingValue;
Clipboard.setData(ClipboardData(
text: value.selection.textInside(value.text),
));
delegate.textEditingValue = TextEditingValue(
text: value.text,
selection: TextSelection.collapsed(offset: value.selection.end),
);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
}
/// Paste the current clipboard selection (obtained from [Clipboard]) into
/// the text field managed by the given `delegate`, replacing its current
/// selection, if any. Then, hide the toolbar.
///
/// This is called by subclasses when their paste affordance is activated by
/// the user.
///
/// This function is asynchronous since interacting with the clipboard is
/// asynchronous. Race conditions may exist with this API as currently
/// implemented.
// TODO(ianh): https://github.com/flutter/flutter/issues/11427
Future<void> handlePaste(TextSelectionDelegate delegate) async {
final TextEditingValue value = delegate.textEditingValue; // Snapshot the input before using `await`.
final ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
delegate.textEditingValue = TextEditingValue(
text: value.selection.textBefore(value.text)
+ data.text
+ value.selection.textAfter(value.text),
selection: TextSelection.collapsed(
offset: value.selection.start + data.text.length
),
);
}
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
delegate.hideToolbar();
}
/// Adjust the selection of the text field managed by the given `delegate` so
/// that everything is selected.
///
/// Does not hide the toolbar.
///
/// This is called by subclasses when their select-all affordance is activated
/// by the user.
void handleSelectAll(TextSelectionDelegate delegate) {
delegate.textEditingValue = TextEditingValue(
text: delegate.textEditingValue.text,
selection: TextSelection(
baseOffset: 0,
extentOffset: delegate.textEditingValue.text.length,
),
);
delegate.bringIntoView(delegate.textEditingValue.selection.extent);
}
}
/// An object that manages a pair of text selection handles.
///
/// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext].
class TextSelectionOverlay {
/// Creates an object that manages overly entries for selection handles.
///
/// The [context] must not be null and must have an [Overlay] as an ancestor.
TextSelectionOverlay({
@required TextEditingValue value,
@required this.context,
this.debugRequiredFor,
@required this.layerLink,
@required this.renderObject,
this.selectionControls,
this.selectionDelegate,
this.dragStartBehavior = DragStartBehavior.start,
}) : assert(value != null),
assert(context != null),
_value = value {
final OverlayState overlay = Overlay.of(context);
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 layerLink;
// TODO(mpcomplete): what if the renderObject is removed or replaced, or
// moves? Not sure what cases I need to handle, or how to handle them.
/// The editable line in which the selected text is being displayed.
final RenderEditable renderObject;
/// Builds text selection handles and toolbar.
final TextSelectionControls selectionControls;
/// The delegate for manipulating the current selection in the owning
/// text field.
final TextSelectionDelegate selectionDelegate;
/// 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;
/// Controls the fade-in and fade-out animations for the toolbar and handles.
static const Duration fadeDuration = Duration(milliseconds: 150);
AnimationController _toolbarController;
Animation<double> get _toolbarOpacity => _toolbarController.view;
TextEditingValue _value;
/// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed.
List<OverlayEntry> _handles;
/// A copy/paste toolbar.
OverlayEntry _toolbar;
TextSelection get _selection => _value.selection;
/// Shows the handles by inserting them into the [context]'s overlay.
void showHandles() {
assert(_handles == null);
_handles = <OverlayEntry>[
OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)),
OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)),
];
Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
}
/// Shows the toolbar by inserting it into the [context]'s overlay.
void showToolbar() {
assert(_toolbar == null);
_toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, debugRequiredFor: debugRequiredFor).insert(_toolbar);
_toolbarController.forward(from: 0.0);
}
/// Updates the overlay after the selection has changed.
///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
/// until the post-frame callbacks phase. Otherwise the update is done
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
void update(TextEditingValue newValue) {
if (_value == newValue)
return;
_value = newValue;
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild);
} else {
_markNeedsBuild();
}
}
/// Causes the overlay to update its rendering.
///
/// This is intended to be called when the [renderObject] may have changed its
/// text metrics (e.g. because the text was scrolled).
void updateForScroll() {
_markNeedsBuild();
}
void _markNeedsBuild([ Duration duration ]) {
if (_handles != null) {
_handles[0].markNeedsBuild();
_handles[1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
}
/// Whether the handles are currently visible.
bool get handlesAreVisible => _handles != null;
/// Whether the toolbar is currently visible.
bool get toolbarIsVisible => _toolbar != null;
/// Hides the overlay.
void hide() {
if (_handles != null) {
_handles[0].remove();
_handles[1].remove();
_handles = null;
}
_toolbar?.remove();
_toolbar = null;
_toolbarController.stop();
}
/// Final cleanup.
void dispose() {
hide();
_toolbarController.dispose();
}
Widget _buildHandle(BuildContext context, _TextSelectionHandlePosition position) {
if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) ||
selectionControls == null)
return Container(); // hide the second handle when collapsed
return _TextSelectionHandleOverlay(
onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
onSelectionHandleTapped: _handleSelectionHandleTapped,
layerLink: layerLink,
renderObject: renderObject,
selection: _selection,
selectionControls: selectionControls,
position: position,
dragStartBehavior: dragStartBehavior,
);
}
Widget _buildToolbar(BuildContext context) {
if (selectionControls == null)
return Container();
// Find the horizontal midpoint, just above the selected text.
final List<TextSelectionPoint> endpoints = renderObject.getEndpointsForSelection(_selection);
final Offset midpoint = Offset(
(endpoints.length == 1) ?
endpoints[0].point.dx :
(endpoints[0].point.dx + endpoints[1].point.dx) / 2.0,
endpoints[0].point.dy - renderObject.preferredLineHeight,
);
final Rect editingRegion = Rect.fromPoints(
renderObject.localToGlobal(Offset.zero),
renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)),
);
return FadeTransition(
opacity: _toolbarOpacity,
child: CompositedTransformFollower(
link: layerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: selectionControls.buildToolbar(context, editingRegion, midpoint, selectionDelegate),
),
);
}
void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
TextPosition textPosition;
switch (position) {
case _TextSelectionHandlePosition.start:
textPosition = newSelection.base;
break;
case _TextSelectionHandlePosition.end:
textPosition =newSelection.extent;
break;
}
selectionDelegate.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty);
selectionDelegate.bringIntoView(textPosition);
}
void _handleSelectionHandleTapped() {
if (_value.selection.isCollapsed) {
if (_toolbar != null) {
_toolbar?.remove();
_toolbar = null;
} else {
showToolbar();
}
}
}
}
/// This widget represents a single draggable text selection handle.
class _TextSelectionHandleOverlay extends StatefulWidget {
const _TextSelectionHandleOverlay({
Key key,
@required this.selection,
@required this.position,
@required this.layerLink,
@required this.renderObject,
@required this.onSelectionHandleChanged,
@required this.onSelectionHandleTapped,
@required this.selectionControls,
this.dragStartBehavior = DragStartBehavior.start,
}) : super(key: key);
final TextSelection selection;
final _TextSelectionHandlePosition position;
final LayerLink layerLink;
final RenderEditable renderObject;
final ValueChanged<TextSelection> onSelectionHandleChanged;
final VoidCallback onSelectionHandleTapped;
final TextSelectionControls selectionControls;
final DragStartBehavior dragStartBehavior;
@override
_TextSelectionHandleOverlayState createState() => _TextSelectionHandleOverlayState();
ValueListenable<bool> get _visibility {
switch (position) {
case _TextSelectionHandlePosition.start:
return renderObject.selectionStartInViewport;
case _TextSelectionHandlePosition.end:
return renderObject.selectionEndInViewport;
}
return null;
}
}
class _TextSelectionHandleOverlayState
extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin {
Offset _dragPosition;
AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: TextSelectionOverlay.fadeDuration, vsync: this);
_handleVisibilityChanged();
widget._visibility.addListener(_handleVisibilityChanged);
}
void _handleVisibilityChanged() {
if (widget._visibility.value) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget._visibility.removeListener(_handleVisibilityChanged);
_handleVisibilityChanged();
widget._visibility.addListener(_handleVisibilityChanged);
}
@override
void dispose() {
widget._visibility.removeListener(_handleVisibilityChanged);
_controller.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {
_dragPosition = details.globalPosition + Offset(0.0, -widget.selectionControls.handleSize.height);
}
void _handleDragUpdate(DragUpdateDetails details) {
_dragPosition += details.delta;
final TextPosition position = widget.renderObject.getPositionForPoint(_dragPosition);
if (widget.selection.isCollapsed) {
widget.onSelectionHandleChanged(TextSelection.fromPosition(position));
return;
}
TextSelection newSelection;
switch (widget.position) {
case _TextSelectionHandlePosition.start:
newSelection = TextSelection(
baseOffset: position.offset,
extentOffset: widget.selection.extentOffset,
);
break;
case _TextSelectionHandlePosition.end:
newSelection = TextSelection(
baseOffset: widget.selection.baseOffset,
extentOffset: position.offset,
);
break;
}
if (newSelection.baseOffset >= newSelection.extentOffset)
return; // don't allow order swapping.
widget.onSelectionHandleChanged(newSelection);
}
void _handleTap() {
widget.onSelectionHandleTapped();
}
@override
Widget build(BuildContext context) {
final List<TextSelectionPoint> endpoints = widget.renderObject.getEndpointsForSelection(widget.selection);
Offset point;
TextSelectionHandleType type;
switch (widget.position) {
case _TextSelectionHandlePosition.start:
point = endpoints[0].point;
type = _chooseType(endpoints[0], TextSelectionHandleType.left, TextSelectionHandleType.right);
break;
case _TextSelectionHandlePosition.end:
// [endpoints] will only contain 1 point for collapsed selections, in
// which case we shouldn't be building the [end] handle.
assert(endpoints.length == 2);
point = endpoints[1].point;
type = _chooseType(endpoints[1], TextSelectionHandleType.right, TextSelectionHandleType.left);
break;
}
final Size viewport = widget.renderObject.size;
point = Offset(
point.dx.clamp(0.0, viewport.width),
point.dy.clamp(0.0, viewport.height),
);
return CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
child: FadeTransition(
opacity: _opacity,
child: GestureDetector(
dragStartBehavior: widget.dragStartBehavior,
onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate,
onTap: _handleTap,
child: Stack(
// Always let the selection handles draw outside of the conceptual
// box where (0,0) is the top left corner of the RenderEditable.
overflow: Overflow.visible,
children: <Widget>[
Positioned(
left: point.dx,
top: point.dy,
child: widget.selectionControls.buildHandle(
context,
type,
widget.renderObject.preferredLineHeight,
),
),
],
),
),
),
);
}
TextSelectionHandleType _chooseType(
TextSelectionPoint endpoint,
TextSelectionHandleType ltrType,
TextSelectionHandleType rtlType,
) {
if (widget.selection.isCollapsed)
return TextSelectionHandleType.collapsed;
assert(endpoint.direction != null);
switch (endpoint.direction) {
case TextDirection.ltr:
return ltrType;
case TextDirection.rtl:
return rtlType;
}
return null;
}
}
/// A gesture detector to respond to non-exclusive event chains for a text field.
///
/// An ordinary [GestureDetector] configured to handle events like tap and
/// double tap will only recognize one or the other. This widget detects both:
/// first the tap and then, if another tap down occurs within a time limit, the
/// double tap.
///
/// See also:
///
/// * [TextField], a Material text field which uses this gesture detector.
/// * [CupertinoTextField], a Cupertino text field which uses this gesture
/// detector.
class TextSelectionGestureDetector extends StatefulWidget {
/// Create a [TextSelectionGestureDetector].
///
/// Multiple callbacks can be called for one sequence of input gesture.
/// The [child] parameter must not be null.
const TextSelectionGestureDetector({
Key key,
this.onTapDown,
this.onForcePressStart,
this.onForcePressEnd,
this.onSingleTapUp,
this.onSingleTapCancel,
this.onSingleLongTapStart,
this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd,
this.onDoubleTapDown,
this.onDragSelectionStart,
this.onDragSelectionUpdate,
this.onDragSelectionEnd,
this.behavior,
@required this.child,
}) : assert(child != null),
super(key: key);
/// Called for every tap down including every tap down that's part of a
/// double click or a long press, except touches that include enough movement
/// to not qualify as taps (e.g. pans and flings).
final GestureTapDownCallback onTapDown;
/// Called when a pointer has tapped down and the force of the pointer has
/// just become greater than [ForcePressGestureDetector.startPressure].
final GestureForcePressStartCallback onForcePressStart;
/// Called when a pointer that had previously triggered [onForcePressStart] is
/// lifted off the screen.
final GestureForcePressEndCallback onForcePressEnd;
/// Called for each distinct tap except for every second tap of a double tap.
/// For example, if the detector was configured [onSingleTapDown] and
/// [onDoubleTapDown], three quick taps would be recognized as a single tap
/// down, followed by a double tap down, followed by a single tap down.
final GestureTapUpCallback onSingleTapUp;
/// Called for each touch that becomes recognized as a gesture that is not a
/// short tap, such as a long tap or drag. It is called at the moment when
/// another gesture from the touch is recognized.
final GestureTapCancelCallback onSingleTapCancel;
/// Called for a single long tap that's sustained for longer than
/// [kLongPressTimeout] but not necessarily lifted. Not called for a
/// double-tap-hold, which calls [onDoubleTapDown] instead.
final GestureLongPressStartCallback onSingleLongTapStart;
/// Called after [onSingleLongTapStart] when the pointer is dragged.
final GestureLongPressMoveUpdateCallback onSingleLongTapMoveUpdate;
/// Called after [onSingleLongTapStart] when the pointer is lifted.
final GestureLongPressEndCallback onSingleLongTapEnd;
/// Called after a momentary hold or a short tap that is close in space and
/// time (within [kDoubleTapTimeout]) to a previous short tap.
final GestureTapDownCallback onDoubleTapDown;
/// Called when a mouse starts dragging to select text.
final GestureDragStartCallback onDragSelectionStart;
/// Called repeatedly as a mouse moves while dragging.
///
/// The frequency of calls is throttled to avoid excessive text layout
/// operations in text fields. The throttling is controlled by the constant
/// [_kDragSelectionUpdateThrottle].
final DragSelectionUpdateCallback onDragSelectionUpdate;
/// Called when a mouse that was previously dragging is released.
final GestureDragEndCallback onDragSelectionEnd;
/// How this gesture detector should behave during hit testing.
///
/// This defaults to [HitTestBehavior.deferToChild].
final HitTestBehavior behavior;
/// Child below this widget.
final Widget child;
@override
State<StatefulWidget> createState() => _TextSelectionGestureDetectorState();
}
class _TextSelectionGestureDetectorState extends State<TextSelectionGestureDetector> {
// Counts down for a short duration after a previous tap. Null otherwise.
Timer _doubleTapTimer;
Offset _lastTapOffset;
// True if a second tap down of a double tap is detected. Used to discard
// subsequent tap up / tap hold of the same tap.
bool _isDoubleTap = false;
@override
void dispose() {
_doubleTapTimer?.cancel();
_dragUpdateThrottleTimer?.cancel();
super.dispose();
}
// The down handler is force-run on success of a single tap and optimistically
// run before a long press success.
void _handleTapDown(TapDownDetails details) {
if (widget.onTapDown != null) {
widget.onTapDown(details);
}
// This isn't detected as a double tap gesture in the gesture recognizer
// because it's 2 single taps, each of which may do different things depending
// on whether it's a single tap, the first tap of a double tap, the second
// tap held down, a clean double tap etc.
if (_doubleTapTimer != null && _isWithinDoubleTapTolerance(details.globalPosition)) {
// If there was already a previous tap, the second down hold/tap is a
// double tap down.
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown(details);
}
_doubleTapTimer.cancel();
_doubleTapTimeout();
_isDoubleTap = true;
}
}
void _handleTapUp(TapUpDetails details) {
if (!_isDoubleTap) {
if (widget.onSingleTapUp != null) {
widget.onSingleTapUp(details);
}
_lastTapOffset = details.globalPosition;
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
}
_isDoubleTap = false;
}
void _handleTapCancel() {
if (widget.onSingleTapCancel != null) {
widget.onSingleTapCancel();
}
}
DragStartDetails _lastDragStartDetails;
DragUpdateDetails _lastDragUpdateDetails;
Timer _dragUpdateThrottleTimer;
void _handleDragStart(DragStartDetails details) {
assert(_lastDragStartDetails == null);
_lastDragStartDetails = details;
if (widget.onDragSelectionStart != null) {
widget.onDragSelectionStart(details);
}
}
void _handleDragUpdate(DragUpdateDetails details) {
_lastDragUpdateDetails = details;
// Only schedule a new timer if there's no one pending.
_dragUpdateThrottleTimer ??= Timer(_kDragSelectionUpdateThrottle, _handleDragUpdateThrottled);
}
/// Drag updates are being throttled to avoid excessive text layouts in text
/// fields. The frequency of invocations is controlled by the constant
/// [_kDragSelectionUpdateThrottle].
///
/// Once the drag gesture ends, any pending drag update will be fired
/// immediately. See [_handleDragEnd].
void _handleDragUpdateThrottled() {
assert(_lastDragStartDetails != null);
assert(_lastDragUpdateDetails != null);
if (widget.onDragSelectionUpdate != null) {
widget.onDragSelectionUpdate(_lastDragStartDetails, _lastDragUpdateDetails);
}
_dragUpdateThrottleTimer = null;
_lastDragUpdateDetails = null;
}
void _handleDragEnd(DragEndDetails details) {
assert(_lastDragStartDetails != null);
if (_dragUpdateThrottleTimer != null) {
// If there's already an update scheduled, trigger it immediately and
// cancel the timer.
_dragUpdateThrottleTimer.cancel();
_handleDragUpdateThrottled();
}
if (widget.onDragSelectionEnd != null) {
widget.onDragSelectionEnd(details);
}
_dragUpdateThrottleTimer = null;
_lastDragStartDetails = null;
_lastDragUpdateDetails = null;
}
void _forcePressStarted(ForcePressDetails details) {
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onForcePressStart != null)
widget.onForcePressStart(details);
}
void _forcePressEnded(ForcePressDetails details) {
if (widget.onForcePressEnd != null)
widget.onForcePressEnd(details);
}
void _handleLongPressStart(LongPressStartDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapStart != null) {
widget.onSingleLongTapStart(details);
}
}
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) {
widget.onSingleLongTapMoveUpdate(details);
}
}
void _handleLongPressEnd(LongPressEndDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) {
widget.onSingleLongTapEnd(details);
}
_isDoubleTap = false;
}
void _doubleTapTimeout() {
_doubleTapTimer = null;
_lastTapOffset = null;
}
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
assert(secondTapOffset != null);
if (_lastTapOffset == null) {
return false;
}
final Offset difference = secondTapOffset - _lastTapOffset;
return difference.distance <= kDoubleTapSlop;
}
@override
Widget build(BuildContext context) {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance
..onTapDown = _handleTapDown
..onTapUp = _handleTapUp
..onTapCancel = _handleTapCancel;
},
);
if (widget.onSingleLongTapStart != null ||
widget.onSingleLongTapMoveUpdate != null ||
widget.onSingleLongTapEnd != null) {
gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.touch),
(LongPressGestureRecognizer instance) {
instance
..onLongPressStart = _handleLongPressStart
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
..onLongPressEnd = _handleLongPressEnd;
},
);
}
if (widget.onDragSelectionStart != null ||
widget.onDragSelectionUpdate != null ||
widget.onDragSelectionEnd != null) {
// TODO(mdebbar): Support dragging in any direction (for multiline text).
// https://github.com/flutter/flutter/issues/28676
gestures[HorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(debugOwner: this, kind: PointerDeviceKind.mouse),
(HorizontalDragGestureRecognizer instance) {
instance
// Text selection should start from the position of the first pointer
// down event.
..dragStartBehavior = DragStartBehavior.down
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
},
);
}
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
gestures[ForcePressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
() => ForcePressGestureRecognizer(debugOwner: this),
(ForcePressGestureRecognizer instance) {
instance
..onStart = widget.onForcePressStart != null ? _forcePressStarted : null
..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null;
},
);
}
return RawGestureDetector(
gestures: gestures,
excludeFromSemantics: true,
behavior: widget.behavior,
child: widget.child,
);
}
}