|
|
|
import 'dart:async';
|
|
|
|
import 'dart:math' as math;
|
|
|
|
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
import 'package:flutter/scheduler.dart';
|
|
|
|
import 'package:flutter_quill/models/documents/nodes/node.dart';
|
|
|
|
|
|
|
|
import 'editor.dart';
|
|
|
|
|
|
|
|
TextSelection localSelection(Node node, TextSelection selection, fromParent) {
|
|
|
|
int base = fromParent ? node.getOffset() : node.getDocumentOffset();
|
|
|
|
assert(base <= selection.end && selection.start <= base + node.length - 1);
|
|
|
|
|
|
|
|
int offset = fromParent ? node.getOffset() : node.getDocumentOffset();
|
|
|
|
return selection.copyWith(
|
|
|
|
baseOffset: math.max(selection.start - offset, 0),
|
|
|
|
extentOffset: math.min(selection.end - offset, node.length - 1));
|
|
|
|
}
|
|
|
|
|
|
|
|
enum _TextSelectionHandlePosition { START, END }
|
|
|
|
|
|
|
|
class EditorTextSelectionOverlay {
|
|
|
|
TextEditingValue value;
|
|
|
|
bool handlesVisible = false;
|
|
|
|
final BuildContext context;
|
|
|
|
final Widget debugRequiredFor;
|
|
|
|
final LayerLink toolbarLayerLink;
|
|
|
|
final LayerLink startHandleLayerLink;
|
|
|
|
final LayerLink endHandleLayerLink;
|
|
|
|
final RenderEditor renderObject;
|
|
|
|
final TextSelectionControls selectionCtrls;
|
|
|
|
final TextSelectionDelegate selectionDelegate;
|
|
|
|
final DragStartBehavior dragStartBehavior;
|
|
|
|
final VoidCallback onSelectionHandleTapped;
|
|
|
|
final ClipboardStatusNotifier clipboardStatus;
|
|
|
|
AnimationController _toolbarController;
|
|
|
|
List<OverlayEntry> _handles;
|
|
|
|
OverlayEntry toolbar;
|
|
|
|
|
|
|
|
EditorTextSelectionOverlay(
|
|
|
|
this.value,
|
|
|
|
this.handlesVisible,
|
|
|
|
this.context,
|
|
|
|
this.debugRequiredFor,
|
|
|
|
this.toolbarLayerLink,
|
|
|
|
this.startHandleLayerLink,
|
|
|
|
this.endHandleLayerLink,
|
|
|
|
this.renderObject,
|
|
|
|
this.selectionCtrls,
|
|
|
|
this.selectionDelegate,
|
|
|
|
this.dragStartBehavior,
|
|
|
|
this.onSelectionHandleTapped,
|
|
|
|
this.clipboardStatus)
|
|
|
|
: assert(value != null),
|
|
|
|
assert(context != null),
|
|
|
|
assert(handlesVisible != null) {
|
|
|
|
OverlayState overlay = Overlay.of(context, rootOverlay: true);
|
|
|
|
assert(
|
|
|
|
overlay != null,
|
|
|
|
);
|
|
|
|
_toolbarController = AnimationController(
|
|
|
|
duration: Duration(milliseconds: 150), vsync: overlay);
|
|
|
|
}
|
|
|
|
|
|
|
|
TextSelection get _selection => value.selection;
|
|
|
|
|
|
|
|
Animation<double> get _toolbarOpacity => _toolbarController.view;
|
|
|
|
|
|
|
|
setHandlesVisible(bool visible) {
|
|
|
|
assert(visible != null);
|
|
|
|
if (handlesVisible == visible) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
handlesVisible = visible;
|
|
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
|
|
SchedulerPhase.persistentCallbacks) {
|
|
|
|
SchedulerBinding.instance.addPostFrameCallback(markNeedsBuild);
|
|
|
|
} else {
|
|
|
|
markNeedsBuild();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
hideHandles() {
|
|
|
|
if (_handles == null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
_handles[0].remove();
|
|
|
|
_handles[1].remove();
|
|
|
|
_handles = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
hideToolbar() {
|
|
|
|
assert(toolbar != null);
|
|
|
|
_toolbarController.stop();
|
|
|
|
toolbar.remove();
|
|
|
|
toolbar = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
showToolbar() {
|
|
|
|
assert(toolbar == null);
|
|
|
|
toolbar = OverlayEntry(builder: _buildToolbar);
|
|
|
|
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)
|
|
|
|
.insert(toolbar);
|
|
|
|
_toolbarController.forward(from: 0.0);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildHandle(
|
|
|
|
BuildContext context, _TextSelectionHandlePosition position) {
|
|
|
|
if ((_selection.isCollapsed &&
|
|
|
|
position == _TextSelectionHandlePosition.END) ||
|
|
|
|
selectionCtrls == null) {
|
|
|
|
return Container();
|
|
|
|
}
|
|
|
|
return Visibility(
|
|
|
|
visible: handlesVisible,
|
|
|
|
child: _TextSelectionHandleOverlay(
|
|
|
|
onSelectionHandleChanged: (TextSelection newSelection) {
|
|
|
|
_handleSelectionHandleChanged(newSelection, position);
|
|
|
|
},
|
|
|
|
onSelectionHandleTapped: onSelectionHandleTapped,
|
|
|
|
startHandleLayerLink: startHandleLayerLink,
|
|
|
|
endHandleLayerLink: endHandleLayerLink,
|
|
|
|
renderObject: renderObject,
|
|
|
|
selection: _selection,
|
|
|
|
selectionControls: selectionCtrls,
|
|
|
|
position: position,
|
|
|
|
dragStartBehavior: dragStartBehavior,
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
update(TextEditingValue newValue) {
|
|
|
|
if (value == newValue) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
value = newValue;
|
|
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
|
|
SchedulerPhase.persistentCallbacks) {
|
|
|
|
SchedulerBinding.instance.addPostFrameCallback(markNeedsBuild);
|
|
|
|
} else {
|
|
|
|
markNeedsBuild();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleSelectionHandleChanged(
|
|
|
|
TextSelection newSelection, _TextSelectionHandlePosition position) {
|
|
|
|
TextPosition textPosition;
|
|
|
|
switch (position) {
|
|
|
|
case _TextSelectionHandlePosition.START:
|
|
|
|
textPosition = newSelection.base;
|
|
|
|
break;
|
|
|
|
case _TextSelectionHandlePosition.END:
|
|
|
|
textPosition = newSelection.extent;
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
throw ('Invalid position');
|
|
|
|
}
|
|
|
|
selectionDelegate.textEditingValue =
|
|
|
|
value.copyWith(selection: newSelection, composing: TextRange.empty);
|
|
|
|
selectionDelegate.bringIntoView(textPosition);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildToolbar(BuildContext context) {
|
|
|
|
if (selectionCtrls == null) {
|
|
|
|
return Container();
|
|
|
|
}
|
|
|
|
|
|
|
|
List<TextSelectionPoint> endpoints =
|
|
|
|
renderObject.getEndpointsForSelection(_selection);
|
|
|
|
|
|
|
|
Rect editingRegion = Rect.fromPoints(
|
|
|
|
renderObject.localToGlobal(Offset.zero),
|
|
|
|
renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)),
|
|
|
|
);
|
|
|
|
|
|
|
|
double baseLineHeight = renderObject.preferredLineHeight(_selection.base);
|
|
|
|
double extentLineHeight =
|
|
|
|
renderObject.preferredLineHeight(_selection.extent);
|
|
|
|
double smallestLineHeight = math.min(baseLineHeight, extentLineHeight);
|
|
|
|
bool isMultiline = endpoints.last.point.dy - endpoints.first.point.dy >
|
|
|
|
smallestLineHeight / 2;
|
|
|
|
|
|
|
|
double midX = isMultiline
|
|
|
|
? editingRegion.width / 2
|
|
|
|
: (endpoints.first.point.dx + endpoints.last.point.dx) / 2;
|
|
|
|
|
|
|
|
Offset midpoint = Offset(
|
|
|
|
midX,
|
|
|
|
endpoints[0].point.dy - baseLineHeight,
|
|
|
|
);
|
|
|
|
|
|
|
|
return FadeTransition(
|
|
|
|
opacity: _toolbarOpacity,
|
|
|
|
child: CompositedTransformFollower(
|
|
|
|
link: toolbarLayerLink,
|
|
|
|
showWhenUnlinked: false,
|
|
|
|
offset: -editingRegion.topLeft,
|
|
|
|
child: selectionCtrls.buildToolbar(
|
|
|
|
context,
|
|
|
|
editingRegion,
|
|
|
|
baseLineHeight,
|
|
|
|
midpoint,
|
|
|
|
endpoints,
|
|
|
|
selectionDelegate,
|
|
|
|
clipboardStatus,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
markNeedsBuild([Duration duration]) {
|
|
|
|
if (_handles != null) {
|
|
|
|
_handles[0].markNeedsBuild();
|
|
|
|
_handles[1].markNeedsBuild();
|
|
|
|
}
|
|
|
|
toolbar?.markNeedsBuild();
|
|
|
|
}
|
|
|
|
|
|
|
|
hide() {
|
|
|
|
if (_handles != null) {
|
|
|
|
_handles[0].remove();
|
|
|
|
_handles[1].remove();
|
|
|
|
_handles = null;
|
|
|
|
}
|
|
|
|
if (toolbar != null) {
|
|
|
|
hideToolbar();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
dispose() {
|
|
|
|
hide();
|
|
|
|
_toolbarController.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
void showHandles() {
|
|
|
|
assert(_handles == null);
|
|
|
|
_handles = <OverlayEntry>[
|
|
|
|
OverlayEntry(
|
|
|
|
builder: (BuildContext context) =>
|
|
|
|
_buildHandle(context, _TextSelectionHandlePosition.START)),
|
|
|
|
OverlayEntry(
|
|
|
|
builder: (BuildContext context) =>
|
|
|
|
_buildHandle(context, _TextSelectionHandlePosition.END)),
|
|
|
|
];
|
|
|
|
|
|
|
|
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)
|
|
|
|
.insertAll(_handles);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TextSelectionHandleOverlay extends StatefulWidget {
|
|
|
|
const _TextSelectionHandleOverlay({
|
|
|
|
Key key,
|
|
|
|
@required this.selection,
|
|
|
|
@required this.position,
|
|
|
|
@required this.startHandleLayerLink,
|
|
|
|
@required this.endHandleLayerLink,
|
|
|
|
@required this.renderObject,
|
|
|
|
@required this.onSelectionHandleChanged,
|
|
|
|
@required this.onSelectionHandleTapped,
|
|
|
|
@required this.selectionControls,
|
|
|
|
this.dragStartBehavior = DragStartBehavior.start,
|
|
|
|
}) : super(key: key);
|
|
|
|
|
|
|
|
final TextSelection selection;
|
|
|
|
final _TextSelectionHandlePosition position;
|
|
|
|
final LayerLink startHandleLayerLink;
|
|
|
|
final LayerLink endHandleLayerLink;
|
|
|
|
final RenderEditor 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 {
|
|
|
|
AnimationController _controller;
|
|
|
|
|
|
|
|
Animation<double> get _opacity => _controller.view;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
|
|
|
|
_controller =
|
|
|
|
AnimationController(duration: Duration(milliseconds: 150), vsync: this);
|
|
|
|
|
|
|
|
_handleVisibilityChanged();
|
|
|
|
widget._visibility.addListener(_handleVisibilityChanged);
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleVisibilityChanged() {
|
|
|
|
if (widget._visibility.value) {
|
|
|
|
_controller.forward();
|
|
|
|
} else {
|
|
|
|
_controller.reverse();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleDragStart(DragStartDetails details) {}
|
|
|
|
|
|
|
|
_handleDragUpdate(DragUpdateDetails details) {
|
|
|
|
TextPosition position =
|
|
|
|
widget.renderObject.getPositionForOffset(details.globalPosition);
|
|
|
|
if (widget.selection.isCollapsed) {
|
|
|
|
widget.onSelectionHandleChanged(TextSelection.fromPosition(position));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool isNormalized =
|
|
|
|
widget.selection.extentOffset >= widget.selection.baseOffset;
|
|
|
|
TextSelection newSelection;
|
|
|
|
switch (widget.position) {
|
|
|
|
case _TextSelectionHandlePosition.START:
|
|
|
|
newSelection = TextSelection(
|
|
|
|
baseOffset:
|
|
|
|
isNormalized ? position.offset : widget.selection.baseOffset,
|
|
|
|
extentOffset:
|
|
|
|
isNormalized ? widget.selection.extentOffset : position.offset,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
case _TextSelectionHandlePosition.END:
|
|
|
|
newSelection = TextSelection(
|
|
|
|
baseOffset:
|
|
|
|
isNormalized ? widget.selection.baseOffset : position.offset,
|
|
|
|
extentOffset:
|
|
|
|
isNormalized ? position.offset : widget.selection.extentOffset,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
widget.onSelectionHandleChanged(newSelection);
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleTap() {
|
|
|
|
if (widget.onSelectionHandleTapped != null)
|
|
|
|
widget.onSelectionHandleTapped();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
LayerLink layerLink;
|
|
|
|
TextSelectionHandleType type;
|
|
|
|
|
|
|
|
switch (widget.position) {
|
|
|
|
case _TextSelectionHandlePosition.START:
|
|
|
|
layerLink = widget.startHandleLayerLink;
|
|
|
|
type = _chooseType(
|
|
|
|
widget.renderObject.textDirection,
|
|
|
|
TextSelectionHandleType.left,
|
|
|
|
TextSelectionHandleType.right,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
case _TextSelectionHandlePosition.END:
|
|
|
|
assert(!widget.selection.isCollapsed);
|
|
|
|
layerLink = widget.endHandleLayerLink;
|
|
|
|
type = _chooseType(
|
|
|
|
widget.renderObject.textDirection,
|
|
|
|
TextSelectionHandleType.right,
|
|
|
|
TextSelectionHandleType.left,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
TextPosition textPosition =
|
|
|
|
widget.position == _TextSelectionHandlePosition.START
|
|
|
|
? widget.selection.base
|
|
|
|
: widget.selection.extent;
|
|
|
|
double lineHeight = widget.renderObject.preferredLineHeight(textPosition);
|
|
|
|
Offset handleAnchor =
|
|
|
|
widget.selectionControls.getHandleAnchor(type, lineHeight);
|
|
|
|
Size handleSize = widget.selectionControls.getHandleSize(lineHeight);
|
|
|
|
|
|
|
|
Rect handleRect = Rect.fromLTWH(
|
|
|
|
-handleAnchor.dx,
|
|
|
|
-handleAnchor.dy,
|
|
|
|
handleSize.width,
|
|
|
|
handleSize.height,
|
|
|
|
);
|
|
|
|
|
|
|
|
Rect interactiveRect = handleRect.expandToInclude(
|
|
|
|
Rect.fromCircle(
|
|
|
|
center: handleRect.center, radius: kMinInteractiveDimension / 2),
|
|
|
|
);
|
|
|
|
RelativeRect padding = RelativeRect.fromLTRB(
|
|
|
|
math.max((interactiveRect.width - handleRect.width) / 2, 0),
|
|
|
|
math.max((interactiveRect.height - handleRect.height) / 2, 0),
|
|
|
|
math.max((interactiveRect.width - handleRect.width) / 2, 0),
|
|
|
|
math.max((interactiveRect.height - handleRect.height) / 2, 0),
|
|
|
|
);
|
|
|
|
|
|
|
|
return CompositedTransformFollower(
|
|
|
|
link: layerLink,
|
|
|
|
offset: interactiveRect.topLeft,
|
|
|
|
showWhenUnlinked: false,
|
|
|
|
child: FadeTransition(
|
|
|
|
opacity: _opacity,
|
|
|
|
child: Container(
|
|
|
|
alignment: Alignment.topLeft,
|
|
|
|
width: interactiveRect.width,
|
|
|
|
height: interactiveRect.height,
|
|
|
|
child: GestureDetector(
|
|
|
|
behavior: HitTestBehavior.translucent,
|
|
|
|
dragStartBehavior: widget.dragStartBehavior,
|
|
|
|
onPanStart: _handleDragStart,
|
|
|
|
onPanUpdate: _handleDragUpdate,
|
|
|
|
onTap: _handleTap,
|
|
|
|
child: Padding(
|
|
|
|
padding: EdgeInsets.only(
|
|
|
|
left: padding.left,
|
|
|
|
top: padding.top,
|
|
|
|
right: padding.right,
|
|
|
|
bottom: padding.bottom,
|
|
|
|
),
|
|
|
|
child: widget.selectionControls.buildHandle(
|
|
|
|
context,
|
|
|
|
type,
|
|
|
|
lineHeight,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
TextSelectionHandleType _chooseType(
|
|
|
|
TextDirection textDirection,
|
|
|
|
TextSelectionHandleType ltrType,
|
|
|
|
TextSelectionHandleType rtlType,
|
|
|
|
) {
|
|
|
|
if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed;
|
|
|
|
|
|
|
|
assert(textDirection != null);
|
|
|
|
switch (textDirection) {
|
|
|
|
case TextDirection.ltr:
|
|
|
|
return ltrType;
|
|
|
|
case TextDirection.rtl:
|
|
|
|
return rtlType;
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class EditorTextSelectionGestureDetector extends StatefulWidget {
|
|
|
|
const EditorTextSelectionGestureDetector({
|
|
|
|
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);
|
|
|
|
|
|
|
|
final GestureTapDownCallback onTapDown;
|
|
|
|
|
|
|
|
final GestureForcePressStartCallback onForcePressStart;
|
|
|
|
|
|
|
|
final GestureForcePressEndCallback onForcePressEnd;
|
|
|
|
|
|
|
|
final GestureTapUpCallback onSingleTapUp;
|
|
|
|
|
|
|
|
final GestureTapCancelCallback onSingleTapCancel;
|
|
|
|
|
|
|
|
final GestureLongPressStartCallback onSingleLongTapStart;
|
|
|
|
|
|
|
|
final GestureLongPressMoveUpdateCallback onSingleLongTapMoveUpdate;
|
|
|
|
|
|
|
|
final GestureLongPressEndCallback onSingleLongTapEnd;
|
|
|
|
|
|
|
|
final GestureTapDownCallback onDoubleTapDown;
|
|
|
|
|
|
|
|
final GestureDragStartCallback onDragSelectionStart;
|
|
|
|
|
|
|
|
final DragSelectionUpdateCallback onDragSelectionUpdate;
|
|
|
|
|
|
|
|
final GestureDragEndCallback onDragSelectionEnd;
|
|
|
|
|
|
|
|
final HitTestBehavior behavior;
|
|
|
|
|
|
|
|
final Widget child;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<StatefulWidget> createState() =>
|
|
|
|
_EditorTextSelectionGestureDetectorState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _EditorTextSelectionGestureDetectorState
|
|
|
|
extends State<EditorTextSelectionGestureDetector> {
|
|
|
|
Timer _doubleTapTimer;
|
|
|
|
Offset _lastTapOffset;
|
|
|
|
bool _isDoubleTap = false;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_doubleTapTimer?.cancel();
|
|
|
|
_dragUpdateThrottleTimer?.cancel();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleTapDown(TapDownDetails details) {
|
|
|
|
if (widget.onTapDown != null) {
|
|
|
|
widget.onTapDown(details);
|
|
|
|
}
|
|
|
|
if (_doubleTapTimer != null &&
|
|
|
|
_isWithinDoubleTapTolerance(details.globalPosition)) {
|
|
|
|
if (widget.onDoubleTapDown != null) {
|
|
|
|
widget.onDoubleTapDown(details);
|
|
|
|
}
|
|
|
|
|
|
|
|
_doubleTapTimer.cancel();
|
|
|
|
_doubleTapTimeout();
|
|
|
|
_isDoubleTap = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleTapUp(TapUpDetails details) {
|
|
|
|
if (!_isDoubleTap) {
|
|
|
|
if (widget.onSingleTapUp != null) {
|
|
|
|
widget.onSingleTapUp(details);
|
|
|
|
}
|
|
|
|
_lastTapOffset = details.globalPosition;
|
|
|
|
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
|
|
|
|
}
|
|
|
|
_isDoubleTap = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleTapCancel() {
|
|
|
|
if (widget.onSingleTapCancel != null) {
|
|
|
|
widget.onSingleTapCancel();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
DragStartDetails _lastDragStartDetails;
|
|
|
|
DragUpdateDetails _lastDragUpdateDetails;
|
|
|
|
Timer _dragUpdateThrottleTimer;
|
|
|
|
|
|
|
|
_handleDragStart(DragStartDetails details) {
|
|
|
|
assert(_lastDragStartDetails == null);
|
|
|
|
_lastDragStartDetails = details;
|
|
|
|
if (widget.onDragSelectionStart != null) {
|
|
|
|
widget.onDragSelectionStart(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleDragUpdate(DragUpdateDetails details) {
|
|
|
|
_lastDragUpdateDetails = details;
|
|
|
|
_dragUpdateThrottleTimer ??=
|
|
|
|
Timer(Duration(milliseconds: 50), _handleDragUpdateThrottled);
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleDragUpdateThrottled() {
|
|
|
|
assert(_lastDragStartDetails != null);
|
|
|
|
assert(_lastDragUpdateDetails != null);
|
|
|
|
if (widget.onDragSelectionUpdate != null) {
|
|
|
|
widget.onDragSelectionUpdate(
|
|
|
|
_lastDragStartDetails, _lastDragUpdateDetails);
|
|
|
|
}
|
|
|
|
_dragUpdateThrottleTimer = null;
|
|
|
|
_lastDragUpdateDetails = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleDragEnd(DragEndDetails details) {
|
|
|
|
assert(_lastDragStartDetails != null);
|
|
|
|
if (_dragUpdateThrottleTimer != null) {
|
|
|
|
_dragUpdateThrottleTimer.cancel();
|
|
|
|
_handleDragUpdateThrottled();
|
|
|
|
}
|
|
|
|
if (widget.onDragSelectionEnd != null) {
|
|
|
|
widget.onDragSelectionEnd(details);
|
|
|
|
}
|
|
|
|
_dragUpdateThrottleTimer = null;
|
|
|
|
_lastDragStartDetails = null;
|
|
|
|
_lastDragUpdateDetails = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
_forcePressStarted(ForcePressDetails details) {
|
|
|
|
_doubleTapTimer?.cancel();
|
|
|
|
_doubleTapTimer = null;
|
|
|
|
if (widget.onForcePressStart != null) {
|
|
|
|
widget.onForcePressStart(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_forcePressEnded(ForcePressDetails details) {
|
|
|
|
if (widget.onForcePressEnd != null) {
|
|
|
|
widget.onForcePressEnd(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleLongPressStart(LongPressStartDetails details) {
|
|
|
|
if (!_isDoubleTap && widget.onSingleLongTapStart != null) {
|
|
|
|
widget.onSingleLongTapStart(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
|
|
|
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) {
|
|
|
|
widget.onSingleLongTapMoveUpdate(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_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;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (secondTapOffset - _lastTapOffset).distance <= kDoubleTapSlop;
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final Map<Type, GestureRecognizerFactory> gestures =
|
|
|
|
<Type, GestureRecognizerFactory>{};
|
|
|
|
|
|
|
|
gestures[_TransparentTapGestureRecognizer] =
|
|
|
|
GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>(
|
|
|
|
() => _TransparentTapGestureRecognizer(debugOwner: this),
|
|
|
|
(_TransparentTapGestureRecognizer instance) {
|
|
|
|
instance
|
|
|
|
..onTapDown = _handleTapDown
|
|
|
|
..onTapUp = _handleTapUp
|
|
|
|
..onTapCancel = _handleTapCancel;
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
if (widget.onSingleLongTapStart != null ||
|
|
|
|
widget.onSingleLongTapMoveUpdate != null ||
|
|
|
|
widget.onSingleLongTapEnd != null) {
|
|
|
|
gestures[LongPressGestureRecognizer] =
|
|
|
|
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
|
|
|
() => LongPressGestureRecognizer(
|
|
|
|
debugOwner: this, kind: PointerDeviceKind.touch),
|
|
|
|
(LongPressGestureRecognizer instance) {
|
|
|
|
instance
|
|
|
|
..onLongPressStart = _handleLongPressStart
|
|
|
|
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
|
|
|
|
..onLongPressEnd = _handleLongPressEnd;
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (widget.onDragSelectionStart != null ||
|
|
|
|
widget.onDragSelectionUpdate != null ||
|
|
|
|
widget.onDragSelectionEnd != null) {
|
|
|
|
gestures[HorizontalDragGestureRecognizer] =
|
|
|
|
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
|
|
|
|
() => HorizontalDragGestureRecognizer(
|
|
|
|
debugOwner: this, kind: PointerDeviceKind.mouse),
|
|
|
|
(HorizontalDragGestureRecognizer instance) {
|
|
|
|
instance
|
|
|
|
..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,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TransparentTapGestureRecognizer extends TapGestureRecognizer {
|
|
|
|
_TransparentTapGestureRecognizer({
|
|
|
|
Object debugOwner,
|
|
|
|
}) : super(debugOwner: debugOwner);
|
|
|
|
|
|
|
|
@override
|
|
|
|
void rejectGesture(int pointer) {
|
|
|
|
if (state == GestureRecognizerState.ready) {
|
|
|
|
acceptGesture(pointer);
|
|
|
|
} else {
|
|
|
|
super.rejectGesture(pointer);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|