// 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/foundation.dart'; 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); /// 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 }) : 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]. 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; /// 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 }) : super( key: key, child: child, feedback: feedback, data: data, childWhenDragging: childWhenDragging, feedbackOffset: feedbackOffset, dragAnchor: dragAnchor, maxSimultaneousDrags: maxSimultaneousDrags, onDragStarted: onDragStarted, onDraggableCanceled: onDraggableCanceled ); @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.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 }) : 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. final DragTargetWillAccept<T> onWillAccept; /// Called when an acceptable piece of data was dropped over this drag target. final DragTargetAccept<T> onAccept; @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); }); } 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 // eeding 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; } 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 ) ); } }