blob: 2d8c250ccce1f29b8f0c80a3be14edd7d4f5f6d8 [file] [log] [blame]
// Copyright 2015 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 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'basic.dart';
import 'binding.dart';
import 'framework.dart';
import 'overlay.dart';
/// Signature for determining whether the given data will be accepted by a [DragTarget].
///
/// Used by [DragTarget.onWillAccept].
typedef bool DragTargetWillAccept<T>(T data);
/// Signature for causing a [DragTarget] to accept the given data.
///
/// Used by [DragTarget.onAccept].
typedef void DragTargetAccept<T>(T data);
/// Signature for building children of a [DragTarget].
///
/// The `candidateData` argument contains the list of drag data that is hovering
/// over this [DragTarget] and that has passed [DragTarget.onWillAccept]. The
/// `rejectedData` argument contains the list of drag data that is hovering over
/// this [DragTarget] and that will not be accepted by the [DragTarget].
///
/// Used by [DragTarget.builder].
typedef Widget DragTargetBuilder<T>(BuildContext context, List<T> candidateData, List<dynamic> rejectedData);
/// Signature for when a [Draggable] is dropped without being accepted by a [DragTarget].
///
/// Used by [Draggable.onDraggableCanceled].
typedef void DraggableCanceledCallback(Velocity velocity, Offset offset);
/// Signature for when a [Draggable] leaves a [DragTarget].
///
/// Used by [DragTarget.onLeave].
typedef void DragTargetLeave<T>(T data);
/// Where the [Draggable] should be anchored during a drag.
enum DragAnchor {
/// Display the feedback anchored at the position of the original child. If
/// feedback is identical to the child, then this means the feedback will
/// exactly overlap the original child when the drag starts.
child,
/// Display the feedback anchored at the position of the touch that started
/// the drag. If feedback is identical to the child, then this means the top
/// left of the feedback will be under the finger when the drag starts. This
/// will likely not exactly overlap the original child, e.g. if the child is
/// big and the touch was not centered. This mode is useful when the feedback
/// is transformed so as to move the feedback to the left by half its width,
/// and up by half its width plus the height of the finger, since then it
/// appears as if putting the finger down makes the touch feedback appear
/// above the finger. (It feels weird for it to appear offset from the
/// original child if it's anchored to the child and not the finger.)
pointer,
}
/// A widget that can be dragged from to a [DragTarget].
///
/// When a draggable widget recognizes the start of a drag gesture, it displays
/// a [feedback] widget that tracks the user's finger across the screen. If the
/// user lifts their finger while on top of a [DragTarget], that target is given
/// the opportunity to accept the [data] carried by the draggable.
///
/// On multitouch devices, multiple drags can occur simultaneously because there
/// can be multiple pointers in contact with the device at once. To limit the
/// number of simultaneous drags, use the [maxSimultaneousDrags] property. The
/// default is to allow an unlimited number of simultaneous drags.
///
/// This widget displays [child] when zero drags are under way. If
/// [childWhenDragging] is non-null, this widget instead displays
/// [childWhenDragging] when one or more drags are underway. Otherwise, this
/// widget always displays [child].
///
/// See also:
///
/// * [DragTarget]
/// * [LongPressDraggable]
class Draggable<T> extends StatefulWidget {
/// Creates a widget that can be dragged to a [DragTarget].
///
/// The [child] and [feedback] arguments must not be null. If
/// [maxSimultaneousDrags] is non-null, it must be non-negative.
const Draggable({
Key key,
@required this.child,
@required this.feedback,
this.data,
this.childWhenDragging,
this.feedbackOffset: Offset.zero,
this.dragAnchor: DragAnchor.child,
this.affinity,
this.maxSimultaneousDrags,
this.onDragStarted,
this.onDraggableCanceled,
this.onDragCompleted,
}) : assert(child != null),
assert(feedback != null),
assert(maxSimultaneousDrags == null || maxSimultaneousDrags >= 0),
super(key: key);
/// The data that will be dropped by this draggable.
final T data;
/// The widget below this widget in the tree.
///
/// This widget displays [child] when zero drags are under way. If
/// [childWhenDragging] is non-null, this widget instead displays
/// [childWhenDragging] when one or more drags are underway. Otherwise, this
/// widget always displays [child].
///
/// The [feedback] widget is shown under the pointer when a drag is under way.
///
/// To limit the number of simultaneous drags on multitouch devices, see
/// [maxSimultaneousDrags].
///
/// {@macro flutter.widgets.child}
final Widget child;
/// The widget to display instead of [child] when one or more drags are under way.
///
/// If this is null, then this widget will always display [child] (and so the
/// drag source representation will not change while a drag is under
/// way).
///
/// The [feedback] widget is shown under the pointer when a drag is under way.
///
/// To limit the number of simultaneous drags on multitouch devices, see
/// [maxSimultaneousDrags].
final Widget childWhenDragging;
/// The widget to show under the pointer when a drag is under way.
///
/// See [child] and [childWhenDragging] for information about what is shown
/// at the location of the [Draggable] itself when a drag is under way.
final Widget feedback;
/// The feedbackOffset can be used to set the hit test target point for the
/// purposes of finding a drag target. It is especially useful if the feedback
/// is transformed compared to the child.
final Offset feedbackOffset;
/// Where this widget should be anchored during a drag.
final DragAnchor dragAnchor;
/// Controls how this widget competes with other gestures to initiate a drag.
///
/// If affinity is null, this widget initiates a drag as soon as it recognizes
/// a tap down gesture, regardless of any directionality. If affinity is
/// horizontal (or vertical), then this widget will compete with other
/// horizontal (or vertical, respectively) gestures.
///
/// For example, if this widget is placed in a vertically scrolling region and
/// has horizontal affinity, pointer motion in the vertical direction will
/// result in a scroll and pointer motion in the horizontal direction will
/// result in a drag. Conversely, if the widget has a null or vertical
/// affinity, pointer motion in any direction will result in a drag rather
/// than in a scroll because the draggable widget, being the more specific
/// widget, will out-compete the [Scrollable] for vertical gestures.
final Axis affinity;
/// How many simultaneous drags to support.
///
/// When null, no limit is applied. Set this to 1 if you want to only allow
/// the drag source to have one item dragged at a time. Set this to 0 if you
/// want to prevent the draggable from actually being dragged.
///
/// If you set this property to 1, consider supplying an "empty" widget for
/// [childWhenDragging] to create the illusion of actually moving [child].
final int maxSimultaneousDrags;
/// Called when the draggable starts being dragged.
final VoidCallback onDragStarted;
/// Called when the draggable is dropped without being accepted by a [DragTarget].
///
/// This function might be called after this widget has been removed from the
/// tree. For example, if a drag was in progress when this widget was removed
/// from the tree and the drag ended up being canceled, this callback will
/// still be called. For this reason, implementations of this callback might
/// need to check [State.mounted] to check whether the state receiving the
/// callback is still in the tree.
final DraggableCanceledCallback onDraggableCanceled;
/// Called when the draggable is dropped and accepted by a [DragTarget].
///
/// This function might be called after this widget has been removed from the
/// tree. For example, if a drag was in progress when this widget was removed
/// from the tree and the drag ended up completing, this callback will
/// still be called. For this reason, implementations of this callback might
/// need to check [State.mounted] to check whether the state receiving the
/// callback is still in the tree.
final VoidCallback onDragCompleted;
/// Creates a gesture recognizer that recognizes the start of the drag.
///
/// Subclasses can override this function to customize when they start
/// recognizing a drag.
@protected
MultiDragGestureRecognizer<MultiDragPointerState> createRecognizer(GestureMultiDragStartCallback onStart) {
switch (affinity) {
case Axis.horizontal:
return new HorizontalMultiDragGestureRecognizer()..onStart = onStart;
case Axis.vertical:
return new VerticalMultiDragGestureRecognizer()..onStart = onStart;
}
return new ImmediateMultiDragGestureRecognizer()..onStart = onStart;
}
@override
_DraggableState<T> createState() => new _DraggableState<T>();
}
/// Makes its child draggable starting from long press.
class LongPressDraggable<T> extends Draggable<T> {
/// Creates a widget that can be dragged starting from long press.
///
/// The [child] and [feedback] arguments must not be null. If
/// [maxSimultaneousDrags] is non-null, it must be non-negative.
const LongPressDraggable({
Key key,
@required Widget child,
@required Widget feedback,
T data,
Widget childWhenDragging,
Offset feedbackOffset: Offset.zero,
DragAnchor dragAnchor: DragAnchor.child,
int maxSimultaneousDrags,
VoidCallback onDragStarted,
DraggableCanceledCallback onDraggableCanceled,
VoidCallback onDragCompleted
}) : super(
key: key,
child: child,
feedback: feedback,
data: data,
childWhenDragging: childWhenDragging,
feedbackOffset: feedbackOffset,
dragAnchor: dragAnchor,
maxSimultaneousDrags: maxSimultaneousDrags,
onDragStarted: onDragStarted,
onDraggableCanceled: onDraggableCanceled,
onDragCompleted: onDragCompleted
);
@override
DelayedMultiDragGestureRecognizer createRecognizer(GestureMultiDragStartCallback onStart) {
return new DelayedMultiDragGestureRecognizer()
..onStart = (Offset position) {
final Drag result = onStart(position);
if (result != null)
HapticFeedback.vibrate();
return result;
};
}
}
class _DraggableState<T> extends State<Draggable<T>> {
@override
void initState() {
super.initState();
_recognizer = widget.createRecognizer(_startDrag);
}
@override
void dispose() {
_disposeRecognizerIfInactive();
super.dispose();
}
// This gesture recognizer has an unusual lifetime. We want to support the use
// case of removing the Draggable from the tree in the middle of a drag. That
// means we need to keep this recognizer alive after this state object has
// been disposed because it's the one listening to the pointer events that are
// driving the drag.
//
// We achieve that by keeping count of the number of active drags and only
// disposing the gesture recognizer after (a) this state object has been
// disposed and (b) there are no more active drags.
GestureRecognizer _recognizer;
int _activeCount = 0;
void _disposeRecognizerIfInactive() {
if (_activeCount > 0)
return;
_recognizer.dispose();
_recognizer = null;
}
void _routePointer(PointerEvent event) {
if (widget.maxSimultaneousDrags != null && _activeCount >= widget.maxSimultaneousDrags)
return;
_recognizer.addPointer(event);
}
_DragAvatar<T> _startDrag(Offset position) {
if (widget.maxSimultaneousDrags != null && _activeCount >= widget.maxSimultaneousDrags)
return null;
Offset dragStartPoint;
switch (widget.dragAnchor) {
case DragAnchor.child:
final RenderBox renderObject = context.findRenderObject();
dragStartPoint = renderObject.globalToLocal(position);
break;
case DragAnchor.pointer:
dragStartPoint = Offset.zero;
break;
}
setState(() {
_activeCount += 1;
});
final _DragAvatar<T> avatar = new _DragAvatar<T>(
overlayState: Overlay.of(context, debugRequiredFor: widget),
data: widget.data,
initialPosition: position,
dragStartPoint: dragStartPoint,
feedback: widget.feedback,
feedbackOffset: widget.feedbackOffset,
onDragEnd: (Velocity velocity, Offset offset, bool wasAccepted) {
if (mounted) {
setState(() {
_activeCount -= 1;
});
} else {
_activeCount -= 1;
_disposeRecognizerIfInactive();
}
if (wasAccepted && widget.onDragCompleted != null)
widget.onDragCompleted();
if (!wasAccepted && widget.onDraggableCanceled != null)
widget.onDraggableCanceled(velocity, offset);
}
);
if (widget.onDragStarted != null)
widget.onDragStarted();
return avatar;
}
@override
Widget build(BuildContext context) {
assert(Overlay.of(context, debugRequiredFor: widget) != null);
final bool canDrag = widget.maxSimultaneousDrags == null ||
_activeCount < widget.maxSimultaneousDrags;
final bool showChild = _activeCount == 0 || widget.childWhenDragging == null;
return new Listener(
onPointerDown: canDrag ? _routePointer : null,
child: showChild ? widget.child : widget.childWhenDragging
);
}
}
/// A widget that receives data when a [Draggable] widget is dropped.
///
/// When a draggable is dragged on top of a drag target, the drag target is
/// asked whether it will accept the data the draggable is carrying. If the user
/// does drop the draggable on top of the drag target (and the drag target has
/// indicated that it will accept the draggable's data), then the drag target is
/// asked to accept the draggable's data.
///
/// See also:
///
/// * [Draggable]
/// * [LongPressDraggable]
class DragTarget<T> extends StatefulWidget {
/// Creates a widget that receives drags.
///
/// The [builder] argument must not be null.
const DragTarget({
Key key,
@required this.builder,
this.onWillAccept,
this.onAccept,
this.onLeave,
}) : super(key: key);
/// Called to build the contents of this widget.
///
/// The builder can build different widgets depending on what is being dragged
/// into this drag target.
final DragTargetBuilder<T> builder;
/// Called to determine whether this widget is interested in receiving a given
/// piece of data being dragged over this drag target.
///
/// Called when a piece of data enters the target. This will be followed by
/// either [onAccept], if the data is dropped, or [onLeave], if the drag
/// leaves the target.
final DragTargetWillAccept<T> onWillAccept;
/// Called when an acceptable piece of data was dropped over this drag target.
final DragTargetAccept<T> onAccept;
/// Called when a given piece of data being dragged over this target leaves
/// the target.
final DragTargetLeave<T> onLeave;
@override
_DragTargetState<T> createState() => new _DragTargetState<T>();
}
List<T> _mapAvatarsToData<T>(List<_DragAvatar<T>> avatars) {
return avatars.map<T>((_DragAvatar<T> avatar) => avatar.data).toList();
}
class _DragTargetState<T> extends State<DragTarget<T>> {
final List<_DragAvatar<T>> _candidateAvatars = <_DragAvatar<T>>[];
final List<_DragAvatar<dynamic>> _rejectedAvatars = <_DragAvatar<dynamic>>[];
bool didEnter(_DragAvatar<dynamic> avatar) {
assert(!_candidateAvatars.contains(avatar));
assert(!_rejectedAvatars.contains(avatar));
if (avatar.data is T && (widget.onWillAccept == null || widget.onWillAccept(avatar.data))) {
setState(() {
_candidateAvatars.add(avatar);
});
return true;
}
_rejectedAvatars.add(avatar);
return false;
}
void didLeave(_DragAvatar<dynamic> avatar) {
assert(_candidateAvatars.contains(avatar) || _rejectedAvatars.contains(avatar));
if (!mounted)
return;
setState(() {
_candidateAvatars.remove(avatar);
_rejectedAvatars.remove(avatar);
});
if (widget.onLeave != null)
widget.onLeave(avatar.data);
}
void didDrop(_DragAvatar<dynamic> avatar) {
assert(_candidateAvatars.contains(avatar));
if (!mounted)
return;
setState(() {
_candidateAvatars.remove(avatar);
});
if (widget.onAccept != null)
widget.onAccept(avatar.data);
}
@override
Widget build(BuildContext context) {
assert(widget.builder != null);
return new MetaData(
metaData: this,
behavior: HitTestBehavior.translucent,
child: widget.builder(context, _mapAvatarsToData<T>(_candidateAvatars), _mapAvatarsToData<dynamic>(_rejectedAvatars))
);
}
}
enum _DragEndKind { dropped, canceled }
typedef void _OnDragEnd(Velocity velocity, Offset offset, bool wasAccepted);
// The lifetime of this object is a little dubious right now. Specifically, it
// lives as long as the pointer is down. Arguably it should self-immolate if the
// overlay goes away. _DraggableState has some delicate logic to continue
// needing this object pointer events even after it has been disposed.
class _DragAvatar<T> extends Drag {
_DragAvatar({
@required this.overlayState,
this.data,
Offset initialPosition,
this.dragStartPoint: Offset.zero,
this.feedback,
this.feedbackOffset: Offset.zero,
this.onDragEnd
}) : assert(overlayState != null),
assert(dragStartPoint != null),
assert(feedbackOffset != null) {
_entry = new OverlayEntry(builder: _build);
overlayState.insert(_entry);
_position = initialPosition;
updateDrag(initialPosition);
}
final T data;
final Offset dragStartPoint;
final Widget feedback;
final Offset feedbackOffset;
final _OnDragEnd onDragEnd;
final OverlayState overlayState;
_DragTargetState<T> _activeTarget;
final List<_DragTargetState<T>> _enteredTargets = <_DragTargetState<T>>[];
Offset _position;
Offset _lastOffset;
OverlayEntry _entry;
@override
void update(DragUpdateDetails details) {
_position += details.delta;
updateDrag(_position);
}
@override
void end(DragEndDetails details) {
finishDrag(_DragEndKind.dropped, details.velocity);
}
@override
void cancel() {
finishDrag(_DragEndKind.canceled);
}
void updateDrag(Offset globalPosition) {
_lastOffset = globalPosition - dragStartPoint;
_entry.markNeedsBuild();
final HitTestResult result = new HitTestResult();
WidgetsBinding.instance.hitTest(result, globalPosition + feedbackOffset);
final List<_DragTargetState<T>> targets = _getDragTargets(result.path).toList();
bool listsMatch = false;
if (targets.length >= _enteredTargets.length && _enteredTargets.isNotEmpty) {
listsMatch = true;
final Iterator<_DragTargetState<T>> iterator = targets.iterator;
for (int i = 0; i < _enteredTargets.length; i += 1) {
iterator.moveNext();
if (iterator.current != _enteredTargets[i]) {
listsMatch = false;
break;
}
}
}
// If everything's the same, bail early.
if (listsMatch)
return;
// Leave old targets.
_leaveAllEntered();
// Enter new targets.
final _DragTargetState<T> newTarget = targets.firstWhere((_DragTargetState<T> target) {
_enteredTargets.add(target);
return target.didEnter(this);
},
orElse: _null
);
_activeTarget = newTarget;
}
static Null _null() => null;
Iterable<_DragTargetState<T>> _getDragTargets(List<HitTestEntry> path) sync* {
// Look for the RenderBoxes that corresponds to the hit target (the hit target
// widgets build RenderMetaData boxes for us for this purpose).
for (HitTestEntry entry in path) {
if (entry.target is RenderMetaData) {
final RenderMetaData renderMetaData = entry.target;
if (renderMetaData.metaData is _DragTargetState<T>)
yield renderMetaData.metaData;
}
}
}
void _leaveAllEntered() {
for (int i = 0; i < _enteredTargets.length; i += 1)
_enteredTargets[i].didLeave(this);
_enteredTargets.clear();
}
void finishDrag(_DragEndKind endKind, [Velocity velocity]) {
bool wasAccepted = false;
if (endKind == _DragEndKind.dropped && _activeTarget != null) {
_activeTarget.didDrop(this);
wasAccepted = true;
_enteredTargets.remove(_activeTarget);
}
_leaveAllEntered();
_activeTarget = null;
_entry.remove();
_entry = null;
// TODO(ianh): consider passing _entry as well so the client can perform an animation.
if (onDragEnd != null)
onDragEnd(velocity ?? Velocity.zero, _lastOffset, wasAccepted);
}
Widget _build(BuildContext context) {
final RenderBox box = overlayState.context.findRenderObject();
final Offset overlayTopLeft = box.localToGlobal(Offset.zero);
return new Positioned(
left: _lastOffset.dx - overlayTopLeft.dx,
top: _lastOffset.dy - overlayTopLeft.dy,
child: new IgnorePointer(
child: feedback
)
);
}
}