Drag and drop are often used when we want to move an item. If the list is long it’s necessary to be scrollable while dragging. The ListView widget is scrollable by default but if we introduce Draggable class into it for each item we can’t scroll the view while dragging an item. Let’s solve this problem.
Base code with Draggable class
Let’s look at the base code first.
class ScrollableDraggable extends StatefulWidget {
@override
State<StatefulWidget> createState() => _ScrollingDragger();
}
class _ScrollingDragger extends State<ScrollableDraggable> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("Scrollable Draggable sample"),
),
body: _createContents(),
),
);
}
Widget _createContents() {
final listView = ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
final data = ListTile(title: Text("data-$index"));
final draggable = Draggable(
child: _decorate(data),
feedback: Material(
child: ConstrainedBox(
constraints:
BoxConstraints(maxWidth: MediaQuery.of(context).size.width),
child: _decorate(data, color: Colors.red),
),
),
);
return draggable;
},
);
return listView;
}
Widget _decorate(Widget child, {Color color = Colors.black}) {
return Container(
child: child,
decoration:
BoxDecoration(border: Border.all(color: color, width: 1)),
);
}
}
As you can see in the video below, it is not scrollable while dragging.
If you get some errors when using Draggable, go to the following post and check how to resolve them.
Get the current position by Listener
Firstly, we need to know where the current position it is on the device. Listener class provides the information.
Widget _createListener(Widget child) {
return Listener(
child: child,
onPointerMove: (PointerMoveEvent event) {
print("x: ${event.position.dx}, y: ${event.position.dy}");
},
);
}
We can see the following output.
I/flutter ( 4972): x: 224.989013671875, y: 213.984375
I/flutter ( 4972): x: 224.483642578125, y: 211.484375
I/flutter ( 4972): x: 221.484375, y: 207.98828125
I/flutter ( 4972): x: 219.990234375, y: 206.484375
Check the following post if you want to know more about how to get widget positions.
Get the position of a Widget
We want to scroll the view when the current position is in one of the red rectangles.
It means that we need to know where the widget is drawn. What we want to know is the start position, width and height of the widget. We can get the information from RenderBox but it isn’t accessible from the widget directly. findRenderObject
of BuildContext
function returns RenderObject
and we can get the position from the object. build
function has the context argument and we can call the findRenderObject
in the build function.
Widget build(BuildContext context) {}
However, the widget must be rendered once before getting the information. Therefore, it needs to be accessed via a global key. Let’s define the key. The key must be created only once within the widget lifetime.
final _listViewKey = GlobalKey();
Then, specify it to the key argument of the target widget.
ListView.builder(
key: _listViewKey,
...
Don’t create the new instance in build function like this below. It doesn’t work.
// BAD: it doesn't work!!!
ListView.builder(
key: GlobalKey(),
...
Once we set the global key to the target widget, we can get the render info via the key.
Widget _createListener(Widget child) {
return Listener(
child: child,
onPointerMove: (PointerMoveEvent event) {
RenderBox render =
_listViewKey.currentContext?.findRenderObject() as RenderBox;
Offset position = render.localToGlobal(Offset.zero);
double topY = position.dy;
double bottomY = topY + render.size.height;
// I/flutter ( 4972): x: 80.0, y: 80.0, height: 560.0, width: 360.0
print("x: ${position.dy}, "
"y: ${position.dy}, "
"height: ${render.size.height}, "
"width: ${render.size.width}");
},
);
}
OK! We could get the information where the widget is.
Define the area to scroll
The next step is to define the area to scroll. Let’s define the scroll detected range. Please adjust the value according to your needs.
const detectedRange = 100;
What we need to know is the top and bottom positions of the parent widget.
Widget _createListener(Widget child) {
return Listener(
child: child,
onPointerMove: (PointerMoveEvent event) {
RenderBox render =
_listViewKey.currentContext?.findRenderObject() as RenderBox;
Offset position = render.localToGlobal(Offset.zero);
double topY = position.dy; // top position of the widget
double bottomY = topY + render.size.height; // bottom position of the widget
const detectedRange = 100;
if (event.position.dy < topY + detectedRange) {
// code to scroll up
}
if (event.position.dy > bottomY - detectedRange) {
// code to scroll down
}
...
It’s easy to calculate it.
Move the position
We need to write logic to move up/down. To control the scroll, we need an additional variable which is ScrollController
. It also needs to be initialized only once and dispose must be called.
final ScrollController _scroller = ScrollController();
@override
void dispose() {
_scroller.dispose();
super.dispose();
}
Let’s set it to the ListView.
ListView.builder(
key: _listViewKey,
controller: _scroller, // set the controller
We can move the view by calling jumpTo
function.
const moveDistance = 3;
if (event.position.dy < topY + detectedRange) {
var to = _scroller.offset - moveDistance;
to = (to < 0) ? 0 : to;
_scroller.jumpTo(to);
}
if (event.position.dy > bottomY - detectedRange) {
_scroller.jumpTo(_scroller.offset + moveDistance);
}
moveDistance
is like a movement speed. It should be small enough because the onPointerMove
event is called again and again whenever the current position changes.
Scrolling until the last item comes to the half of the height
If you need to drop the item on the last item you might need to scroll the view over the scroll detected area. Let’s add an empty box to the ListView in this case.
Widget _createContents() {
final itemCount = 20;
final listView = ListView.builder(
key: _listViewKey,
controller: _scroller,
itemCount: itemCount + 1, // Plus one for the empty box
itemBuilder: (context, index) {
final draggable = ... // create a widget here
if (index != itemCount) {
return draggable;
}
return const SizedBox(height: 250);
},
);
return _createListener(listView);
}
Scrolling only while dragging
In the current implementation, the view is scrolled when we touch the empty box area. To solve this problem, we need to add a variable to control the scroll behavior.
bool _isDragging = false;
The variable needs to be true when the drag gesture starts and false when the drag gesture ends.
final draggable = Draggable(
...
onDragStarted: () => _isDragging = true,
onDragEnd: (details) => _isDragging = false,
onDraggableCanceled: (velocity, offset) => _isDragging = false,
);
Let’s add the check in onPointerMove
in our Listener.
Widget _createListener(Widget child) {
return Listener(
child: child,
onPointerMove: (PointerMoveEvent event) {
if (!_isDragging) {
return;
}
...
With this check, its view isn’t scrollable unless one of the items is being dragged.
End
The final behavior looks like this.
Visit my repository if you want to check the complete code.
Comments
Thank you so much for this tutorial! It helped me a lot in my project!
Thank you for the positive feedback. I’m glad I could help you out.
I need to have a draggable widget inside a Listview widget. This is possible? This is my question: https://stackoverflow.com/questions/75006339/how-to-use-draggable-in-listview-on-flutter
It seems to be impossible. Dragging an item is a similar action to scrolling. I tried to set Axis.horizontal to affinity property but it didn’t work as expected.
I’ve read your question in Stackoverflow but I guess what you want is not Draggable but something else like GestureDetector to add a tap event. If it is what you want, the following code might help you out.
Hi Yuto,
First off, I must say great tutorial and something I was looking for. Following this tutorial I tried my hand at implementing nested listviews and drag and drop between them. And ran into a problem right away – Listener works in the listview only if the drag is inside the same listview. If I drag to any other listview things don’t work as expected. Can you do a follow up on how to achieve this?
Hi Pankaj,
Did you implement Listener in the other ListView? If it doesn’t work, you share a GlobalKey with the other ListViews.
A different GlobalKey must be assigned to the other ListView to get the other ListView’s widget position.
I can imagine 2 behaviors from your explanation. The explanation above is for the second one.
* Scrolling the original ListView A even though the dragged item is on the other ListView B
* Scrolling the other ListView B when the dragged item is on the ListView B
If you want to achieve the first one, I think you need to place a Container that covers all areas.
If you need to support dragging scroll for the 2 ListViews, you need to check in the Listener from which ListView the dragged item comes.
As far as I remember, the Listener implemented in the child widget takes the behavior over the parent one. If you implement Listener for all ListViews, it doesn’t work as expected.