diff --git a/packages/flutter/lib/src/widgets/drag_target.dart b/packages/flutter/lib/src/widgets/drag_target.dart index 6c3be3e6db7..61120429887 100644 --- a/packages/flutter/lib/src/widgets/drag_target.dart +++ b/packages/flutter/lib/src/widgets/drag_target.dart @@ -36,6 +36,15 @@ typedef DragTargetBuilder = Widget Function(BuildContext context, List can /// Used by [Draggable.onDraggableCanceled]. typedef DraggableCanceledCallback = void Function(Velocity velocity, Offset offset); +/// Signature for when the draggable is dropped. +/// +/// The velocity and offset at which the pointer was moving when the draggable +/// was dropped is available in the [DraggableDetails]. Also included in the +/// `details` is whether the draggable's [DragTarget] accepted it. +/// +/// Used by [Draggable.onDragEnd] +typedef DragEndCallback = void Function(DraggableDetails details); + /// Signature for when a [Draggable] leaves a [DragTarget]. /// /// Used by [DragTarget.onLeave]. @@ -100,6 +109,7 @@ class Draggable extends StatefulWidget { this.maxSimultaneousDrags, this.onDragStarted, this.onDraggableCanceled, + this.onDragEnd, this.onDragCompleted, this.ignoringFeedbackSemantics = true, }) : assert(child != null), @@ -229,6 +239,16 @@ class Draggable extends StatefulWidget { /// callback is still in the tree. final VoidCallback onDragCompleted; + /// Called when the draggable is dropped. + /// + /// The velocity and offset at which the pointer was moving when it was + /// dropped is available in the [DraggableDetails]. Also included in the + /// `details` is whether the draggable's [DragTarget] accepted it. + /// + /// This function will only be called while this widget is still mounted to + /// the tree (i.e. [State.mounted] is true). + final DragEndCallback onDragEnd; + /// Creates a gesture recognizer that recognizes the start of the drag. /// /// Subclasses can override this function to customize when they start @@ -266,6 +286,7 @@ class LongPressDraggable extends Draggable { int maxSimultaneousDrags, VoidCallback onDragStarted, DraggableCanceledCallback onDraggableCanceled, + DragEndCallback onDragEnd, VoidCallback onDragCompleted, this.hapticFeedbackOnStart = true, bool ignoringFeedbackSemantics = true, @@ -281,6 +302,7 @@ class LongPressDraggable extends Draggable { maxSimultaneousDrags: maxSimultaneousDrags, onDragStarted: onDragStarted, onDraggableCanceled: onDraggableCanceled, + onDragEnd: onDragEnd, onDragCompleted: onDragCompleted, ignoringFeedbackSemantics: ignoringFeedbackSemantics, ); @@ -372,6 +394,13 @@ class _DraggableState extends State> { _activeCount -= 1; _disposeRecognizerIfInactive(); } + if (mounted && widget.onDragEnd != null) { + widget.onDragEnd(DraggableDetails( + wasAccepted: wasAccepted, + velocity: velocity, + offset: offset + )); + } if (wasAccepted && widget.onDragCompleted != null) widget.onDragCompleted(); if (!wasAccepted && widget.onDraggableCanceled != null) @@ -396,6 +425,38 @@ class _DraggableState extends State> { } } +/// Represents the details when a specific pointer event occurred on +/// the [Draggable]. +/// +/// This includes the [Velocity] at which the pointer was moving and [Offset] +/// when the draggable event occurred, and whether its [DragTarget] accepted it. +/// +/// Also, this is the details object for callbacks that use [DragEndCallback]. +class DraggableDetails { + /// Creates details for a [DraggableDetails]. + /// + /// If [wasAccepted] is not specified, it will default to `false`. + /// + /// The [velocity] or [offset] arguments must not be `null`. + DraggableDetails({ + this.wasAccepted = false, + @required this.velocity, + @required this.offset + }) : assert(velocity != null), + assert(offset != null); + + /// Determines whether the [DragTarget] accepted this draggable. + final bool wasAccepted; + + /// The velocity at which the pointer was moving when the specific pointer + /// event occurred on the draggable. + final Velocity velocity; + + /// The global position when the specific pointer event occurred on + /// the draggable. + final Offset offset; +} + /// 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 diff --git a/packages/flutter/test/widgets/draggable_test.dart b/packages/flutter/test/widgets/draggable_test.dart index dcec8aece78..3f5fc7285b0 100644 --- a/packages/flutter/test/widgets/draggable_test.dart +++ b/packages/flutter/test/widgets/draggable_test.dart @@ -899,6 +899,77 @@ void main() { expect(onDraggableCanceledOffset, equals(Offset(flingStart.dx, flingStart.dy) + const Offset(0.0, 100.0))); }); + testWidgets('Drag and drop - onDragEnd not called if dropped on non-accepting target', (WidgetTester tester) async { + final List accepted = []; + bool onDragEndCalled = false; + DraggableDetails onDragEndDraggableDetails; + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + Draggable( + data: 1, + child: const Text('Source'), + feedback: const Text('Dragging'), + onDragEnd: (DraggableDetails details) { + onDragEndCalled = true; + onDragEndDraggableDetails = details; + }, + ), + DragTarget( + builder: (BuildContext context, List data, List rejects) { + return Container( + height: 100.0, + child: const Text('Target'), + ); + }, + onWillAccept: (int data) => false, + ), + ], + ), + )); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsNothing); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + final Offset firstLocation = tester.getTopLeft(find.text('Source')); + final TestGesture gesture = await tester.startGesture(firstLocation, pointer: 7); + await tester.pump(); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + final Offset secondLocation = tester.getCenter(find.text('Target')); + await gesture.moveTo(secondLocation); + await tester.pump(); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + await gesture.up(); + await tester.pump(); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsNothing); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isTrue); + expect(onDragEndDraggableDetails, isNotNull); + expect(onDragEndDraggableDetails.wasAccepted, isFalse); + expect(onDragEndDraggableDetails.velocity, equals(Velocity.zero)); + expect(onDragEndDraggableDetails.offset, + equals( + Offset(secondLocation.dx, secondLocation.dy - firstLocation.dy))); + }); + testWidgets('Drag and drop - onDragCompleted not called if dropped on non-accepting target', (WidgetTester tester) async { final List accepted = []; bool onDragCompletedCalled = false; @@ -963,6 +1034,138 @@ void main() { expect(onDragCompletedCalled, isFalse); }); + testWidgets('Drag and drop - onDragEnd called if dropped on accepting target', (WidgetTester tester) async { + final List accepted = []; + bool onDragEndCalled = false; + DraggableDetails onDragEndDraggableDetails; + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + Draggable( + data: 1, + child: const Text('Source'), + feedback: const Text('Dragging'), + onDragEnd: (DraggableDetails details) { + onDragEndCalled = true; + onDragEndDraggableDetails = details; + }, + ), + DragTarget( + builder: (BuildContext context, List data, List rejects) { + return Container(height: 100.0, child: const Text('Target')); + }, + onAccept: accepted.add, + ), + ], + ), + )); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsNothing); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + final Offset firstLocation = tester.getCenter(find.text('Source')); + final TestGesture gesture = await tester.startGesture(firstLocation, pointer: 7); + await tester.pump(); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + final Offset secondLocation = tester.getCenter(find.text('Target')); + await gesture.moveTo(secondLocation); + await tester.pump(); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + await gesture.up(); + await tester.pump(); + + final Offset droppedLocation = tester.getTopLeft(find.text('Target')); + expect(accepted, equals([1])); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsNothing); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isTrue); + expect(onDragEndDraggableDetails, isNotNull); + expect(onDragEndDraggableDetails.wasAccepted, isTrue); + expect(onDragEndDraggableDetails.velocity, equals(Velocity.zero)); + expect(onDragEndDraggableDetails.offset, + equals( + Offset(droppedLocation.dx, secondLocation.dy - firstLocation.dy))); + }); + + testWidgets('DragTarget does not call onDragEnd when remove from the tree', (WidgetTester tester) async { + final List events = []; + Offset firstLocation, secondLocation; + int timesOnDragEndCalled = 0; + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + Draggable( + data: 1, + child: const Text('Source'), + feedback: const Text('Dragging'), + onDragEnd: (DraggableDetails details) { + timesOnDragEndCalled++; + }, + ), + DragTarget( + builder: (BuildContext context, List data, List rejects) { + return const Text('Target'); + }, + onAccept: (int data) { + events.add('drop'); + }, + ), + ], + ), + )); + + expect(events, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + + expect(events, isEmpty); + await tester.tap(find.text('Source')); + expect(events, isEmpty); + + firstLocation = tester.getCenter(find.text('Source')); + final TestGesture gesture = await tester.startGesture(firstLocation, pointer: 7); + await tester.pump(); + + await tester.pump(const Duration(seconds: 20)); + + secondLocation = tester.getCenter(find.text('Target')); + await gesture.moveTo(secondLocation); + await tester.pump(); + + await tester.pumpWidget(MaterialApp( + home: Column( + children: const [ + Draggable( + data: 1, + child: Text('Source'), + feedback: Text('Dragging') + ), + ] + ) + )); + + expect(events, isEmpty); + expect(timesOnDragEndCalled, equals(1)); + await gesture.up(); + await tester.pump(); + }); + testWidgets('Drag and drop - onDragCompleted called if dropped on accepting target', (WidgetTester tester) async { final List accepted = []; bool onDragCompletedCalled = false; @@ -1533,6 +1736,85 @@ void main() { expect(events, equals(['tap'])); }); + testWidgets('long-press draggable calls onDragEnd called if dropped on accepting target', (WidgetTester tester) async { + final List accepted = []; + bool onDragEndCalled = false; + DraggableDetails onDragEndDraggableDetails; + + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + LongPressDraggable( + data: 1, + child: const Text('Source'), + feedback: const Text('Dragging'), + onDragEnd: (DraggableDetails details) { + onDragEndCalled = true; + onDragEndDraggableDetails = details; + }, + ), + DragTarget( + builder: (BuildContext context, List data, List rejects) { + return Container(height: 100.0, child: const Text('Target')); + }, + onAccept: accepted.add, + ), + ], + ), + )); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsNothing); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + final Offset firstLocation = tester.getCenter(find.text('Source')); + final TestGesture gesture = await tester.startGesture(firstLocation, pointer: 7); + await tester.pump(); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsNothing); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + await tester.pump(kLongPressTimeout); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + + final Offset secondLocation = tester.getCenter(find.text('Target')); + await gesture.moveTo(secondLocation); + await tester.pump(); + + expect(accepted, isEmpty); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsOneWidget); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isFalse); + + await gesture.up(); + await tester.pump(); + + final Offset droppedLocation = tester.getTopLeft(find.text('Target')); + expect(accepted, equals([1])); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Dragging'), findsNothing); + expect(find.text('Target'), findsOneWidget); + expect(onDragEndCalled, isTrue); + expect(onDragEndDraggableDetails, isNotNull); + expect(onDragEndDraggableDetails.wasAccepted, isTrue); + expect(onDragEndDraggableDetails.velocity, equals(Velocity.zero)); + expect(onDragEndDraggableDetails.offset, + equals( + Offset(droppedLocation.dx, secondLocation.dy - firstLocation.dy))); + }); + testWidgets('long-press draggable calls onDragCompleted called if dropped on accepting target', (WidgetTester tester) async { final List accepted = []; bool onDragCompletedCalled = false;