diff --git a/packages/flutter/lib/src/widgets/drag_target.dart b/packages/flutter/lib/src/widgets/drag_target.dart index 1d8fe0bbd2..1332da2802 100644 --- a/packages/flutter/lib/src/widgets/drag_target.dart +++ b/packages/flutter/lib/src/widgets/drag_target.dart @@ -15,6 +15,9 @@ typedef bool DragTargetWillAccept(T data); typedef void DragTargetAccept(T data); typedef Widget DragTargetBuilder(BuildContext context, List candidateData, List rejectedData); +/// Called when a [Draggable] is dropped without being accepted by a [DragTarget]. +typedef void OnDraggableCanceled(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 @@ -45,7 +48,8 @@ abstract class DraggableBase extends StatefulWidget { this.feedback, this.feedbackOffset: Offset.zero, this.dragAnchor: DragAnchor.child, - this.maxSimultaneousDrags + this.maxSimultaneousDrags, + this.onDraggableCanceled }) : super(key: key) { assert(child != null); assert(feedback != null); @@ -80,6 +84,9 @@ abstract class DraggableBase extends StatefulWidget { /// dragged at a time. final int maxSimultaneousDrags; + /// Called when the draggable is dropped without being accepted by a [DragTarget]. + final OnDraggableCanceled onDraggableCanceled; + /// Should return a new MultiDragGestureRecognizer instance /// constructed with the given arguments. MultiDragGestureRecognizer createRecognizer(GestureMultiDragStartCallback onStart); @@ -98,7 +105,8 @@ class Draggable extends DraggableBase { Widget feedback, Offset feedbackOffset: Offset.zero, DragAnchor dragAnchor: DragAnchor.child, - int maxSimultaneousDrags + int maxSimultaneousDrags, + OnDraggableCanceled onDraggableCanceled }) : super( key: key, data: data, @@ -107,7 +115,8 @@ class Draggable extends DraggableBase { feedback: feedback, feedbackOffset: feedbackOffset, dragAnchor: dragAnchor, - maxSimultaneousDrags: maxSimultaneousDrags + maxSimultaneousDrags: maxSimultaneousDrags, + onDraggableCanceled: onDraggableCanceled ); @override @@ -127,7 +136,8 @@ class HorizontalDraggable extends DraggableBase { Widget feedback, Offset feedbackOffset: Offset.zero, DragAnchor dragAnchor: DragAnchor.child, - int maxSimultaneousDrags + int maxSimultaneousDrags, + OnDraggableCanceled onDraggableCanceled }) : super( key: key, data: data, @@ -136,7 +146,8 @@ class HorizontalDraggable extends DraggableBase { feedback: feedback, feedbackOffset: feedbackOffset, dragAnchor: dragAnchor, - maxSimultaneousDrags: maxSimultaneousDrags + maxSimultaneousDrags: maxSimultaneousDrags, + onDraggableCanceled: onDraggableCanceled ); @override @@ -156,7 +167,8 @@ class VerticalDraggable extends DraggableBase { Widget feedback, Offset feedbackOffset: Offset.zero, DragAnchor dragAnchor: DragAnchor.child, - int maxSimultaneousDrags + int maxSimultaneousDrags, + OnDraggableCanceled onDraggableCanceled }) : super( key: key, data: data, @@ -165,7 +177,8 @@ class VerticalDraggable extends DraggableBase { feedback: feedback, feedbackOffset: feedbackOffset, dragAnchor: dragAnchor, - maxSimultaneousDrags: maxSimultaneousDrags + maxSimultaneousDrags: maxSimultaneousDrags, + onDraggableCanceled: onDraggableCanceled ); @override @@ -184,7 +197,8 @@ class LongPressDraggable extends DraggableBase { Widget feedback, Offset feedbackOffset: Offset.zero, DragAnchor dragAnchor: DragAnchor.child, - int maxSimultaneousDrags + int maxSimultaneousDrags, + OnDraggableCanceled onDraggableCanceled }) : super( key: key, data: data, @@ -193,7 +207,8 @@ class LongPressDraggable extends DraggableBase { feedback: feedback, feedbackOffset: feedbackOffset, dragAnchor: dragAnchor, - maxSimultaneousDrags: maxSimultaneousDrags + maxSimultaneousDrags: maxSimultaneousDrags, + onDraggableCanceled: onDraggableCanceled ); @override @@ -248,9 +263,11 @@ class _DraggableState extends State> { dragStartPoint: dragStartPoint, feedback: config.feedback, feedbackOffset: config.feedbackOffset, - onDragEnd: () { + onDragEnd: (Velocity velocity, Offset offset, bool wasAccepted) { setState(() { _activeCount -= 1; + if (!wasAccepted && config.onDraggableCanceled != null) + config.onDraggableCanceled(velocity, offset); }); } ); @@ -341,6 +358,7 @@ class _DragTargetState extends State> { 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 @@ -370,7 +388,7 @@ class _DragAvatar extends Drag { final Point dragStartPoint; final Widget feedback; final Offset feedbackOffset; - final VoidCallback onDragEnd; + final _OnDragEnd onDragEnd; _DragTargetState _activeTarget; bool _activeTargetWillAcceptDrop = false; @@ -387,7 +405,7 @@ class _DragAvatar extends Drag { @override void end(Velocity velocity) { - finish(_DragEndKind.dropped); + finish(_DragEndKind.dropped, velocity); } @override @@ -422,19 +440,23 @@ class _DragAvatar extends Drag { return null; } - void finish(_DragEndKind endKind) { + void finish(_DragEndKind endKind, [Velocity velocity]) { + bool wasAccepted = false; if (_activeTarget != null) { - if (endKind == _DragEndKind.dropped && _activeTargetWillAcceptDrop) + if (endKind == _DragEndKind.dropped && _activeTargetWillAcceptDrop) { _activeTarget.didDrop(data); - else + wasAccepted = true; + } else { _activeTarget.didLeave(data); + } } _activeTarget = null; _activeTargetWillAcceptDrop = false; _entry.remove(); _entry = null; + // TODO(ianh): consider passing _entry as well so the client can perform an animation. if (onDragEnd != null) - onDragEnd(); + onDragEnd(velocity ?? Velocity.zero, _lastOffset, wasAccepted); } Widget _build(BuildContext context) { diff --git a/packages/flutter/test/widget/draggable_test.dart b/packages/flutter/test/widget/draggable_test.dart index b297c9a519..1bd599e2e8 100644 --- a/packages/flutter/test/widget/draggable_test.dart +++ b/packages/flutter/test/widget/draggable_test.dart @@ -9,7 +9,7 @@ import 'package:test/test.dart'; void main() { test('Drag and drop - control test', () { testWidgets((WidgetTester tester) { - List accepted = []; + List accepted = []; tester.pumpWidget(new MaterialApp( routes: { @@ -62,7 +62,7 @@ void main() { gesture.up(); tester.pump(); - expect(accepted, equals([1])); + expect(accepted, equals([1])); expect(tester.findText('Source'), isNotNull); expect(tester.findText('Dragging'), isNull); expect(tester.findText('Target'), isNotNull); @@ -553,4 +553,204 @@ void main() { }); }); + + test('Drag and drop - onDraggableDropped not called if dropped on accepting target', () { + testWidgets((WidgetTester tester) { + List accepted = []; + bool onDraggableCanceledCalled = false; + + tester.pumpWidget(new MaterialApp( + routes: { + '/': (BuildContext context) { return new Column( + children: [ + new Draggable( + data: 1, + child: new Text('Source'), + feedback: new Text('Dragging'), + onDraggableCanceled: (Velocity velocity, Offset offset) { + onDraggableCanceledCalled = true; + } + ), + new DragTarget( + builder: (BuildContext context, List data, List rejects) { + return new Container( + height: 100.0, + child: new Text('Target') + ); + }, + onAccept: (int data) { + accepted.add(data); + } + ), + ]); + }, + } + )); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + + Point firstLocation = tester.getCenter(tester.findText('Source')); + TestGesture gesture = tester.startGesture(firstLocation, pointer: 7); + tester.pump(); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNotNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + + Point secondLocation = tester.getCenter(tester.findText('Target')); + gesture.moveTo(secondLocation); + tester.pump(); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNotNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + + gesture.up(); + tester.pump(); + + expect(accepted, equals([1])); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + }); + }); + + test('Drag and drop - onDraggableDropped called if dropped on non-accepting target', () { + testWidgets((WidgetTester tester) { + List accepted = []; + bool onDraggableCanceledCalled = false; + Velocity onDraggableCanceledVelocity; + Offset onDraggableCanceledOffset; + + tester.pumpWidget(new MaterialApp( + routes: { + '/': (BuildContext context) { return new Column( + children: [ + new Draggable( + data: 1, + child: new Text('Source'), + feedback: new Text('Dragging'), + onDraggableCanceled: (Velocity velocity, Offset offset) { + onDraggableCanceledCalled = true; + onDraggableCanceledVelocity = velocity; + onDraggableCanceledOffset = offset; + } + ), + new DragTarget( + builder: (BuildContext context, List data, List rejects) { + return new Container( + height: 100.0, + child: new Text('Target') + ); + }, + onWillAccept: (int data) => false + ), + ]); + }, + } + )); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + + Point firstLocation = tester.getTopLeft(tester.findText('Source')); + TestGesture gesture = tester.startGesture(firstLocation, pointer: 7); + tester.pump(); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNotNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + + Point secondLocation = tester.getCenter(tester.findText('Target')); + gesture.moveTo(secondLocation); + tester.pump(); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNotNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + + gesture.up(); + tester.pump(); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isTrue); + expect(onDraggableCanceledVelocity, equals(Velocity.zero)); + expect(onDraggableCanceledOffset, equals(new Offset(secondLocation.x, secondLocation.y))); + }); + }); + + test('Drag and drop - onDraggableDropped called if dropped on non-accepting target with correct velocity', () { + testWidgets((WidgetTester tester) { + List accepted = []; + bool onDraggableCanceledCalled = false; + Velocity onDraggableCanceledVelocity; + Offset onDraggableCanceledOffset; + + tester.pumpWidget(new MaterialApp( + routes: { + '/': (BuildContext context) { return new Column( + children: [ + new Draggable( + data: 1, + child: new Text('Source'), + feedback: new Text('Source'), + onDraggableCanceled: (Velocity velocity, Offset offset) { + onDraggableCanceledCalled = true; + onDraggableCanceledVelocity = velocity; + onDraggableCanceledOffset = offset; + } + ), + new DragTarget( + builder: (BuildContext context, List data, List rejects) { + return new Container( + height: 100.0, + child: new Text('Target') + ); + }, + onWillAccept: (int data) => false + ), + ]); + }, + } + )); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isFalse); + + Point flingStart = tester.getTopLeft(tester.findText('Source')); + tester.flingFrom(flingStart, new Offset(0.0,100.0), 1000.0); + tester.pump(); + + expect(accepted, isEmpty); + expect(tester.findText('Source'), isNotNull); + expect(tester.findText('Dragging'), isNull); + expect(tester.findText('Target'), isNotNull); + expect(onDraggableCanceledCalled, isTrue); + expect(onDraggableCanceledVelocity.pixelsPerSecond.dx.abs(), lessThan(0.0000001)); + expect((onDraggableCanceledVelocity.pixelsPerSecond.dy - 1000.0).abs(), lessThan(0.0000001)); + expect(onDraggableCanceledOffset, equals(new Offset(flingStart.x, flingStart.y) + new Offset(0.0, 100.0))); + }); + }); } diff --git a/packages/flutter_test/lib/src/instrumentation.dart b/packages/flutter_test/lib/src/instrumentation.dart index b926465268..6a3c418f5e 100644 --- a/packages/flutter_test/lib/src/instrumentation.dart +++ b/packages/flutter_test/lib/src/instrumentation.dart @@ -190,7 +190,7 @@ class Instrumentation { final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * velocity); double timeStamp = 0.0; dispatchEvent(p.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result); - for(int i = 0; i < kMoveCount; i++) { + for(int i = 0; i <= kMoveCount; i++) { final Point location = startLocation + Offset.lerp(Offset.zero, offset, i / kMoveCount); dispatchEvent(p.move(location, timeStamp: new Duration(milliseconds: timeStamp.round())), result); timeStamp += timeStampDelta;