Flutter Scrolling while dragging an item

eye-catch Dart and Flutter

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.

Sponsored links

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.

Sponsored links

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.

https://github.com/yuto-yuto/flutter_samples/blob/main/lib/scrollable_draggable.dart

Comments

  1. Zvik says:

    Thank you so much for this tutorial! It helped me a lot in my project!

  2. jaime says:

    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

    • Yuto Yuto says:

      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.

      
      import 'package:flutter/material.dart';
      
      class TapInListview extends StatefulWidget {
        @override
        _TapInListview createState() => _TapInListview();
      }
      
      class _TapInListview extends State {
        @override
        Widget build(BuildContext context) {
          return SafeArea(
            child: Scaffold(
              appBar: AppBar(
                title: Text("Tap in ListView"),
              ),
              body: ListView.builder(
                itemCount: 30,
                itemBuilder: (context, index) {
                  final child = ListTile(
                    title: Text("Something $index"),
                  );
                  // not work
                  // return Draggable(
                  //   affinity: Axis.horizontal,
                  //   data: child,
                  //   feedback: Material(
                  //     child: ConstrainedBox(
                  //       constraints: BoxConstraints(
                  //           maxWidth: MediaQuery.of(context).size.width),
                  //       child: Container(
                  //         child: child,
                  //         decoration: BoxDecoration(
                  //           border: Border.all(color: Colors.red, width: 1),
                  //         ),
                  //       ),
                  //     ),
                  //   ),
                  //   childWhenDragging: Opacity(opacity: 0.5, child: child),
                  //   child: child,
                  // );
      
                  return GestureDetector(
                    child: child,
                    onTap: () {
                      debugPrint("onTap on index $index");
                    },
                  );
                },
              ),
            ),
          );
        }
      }
      
  3. Pankaj Agarwal says:

    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?

    • Yuto Yuto says:

      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.

      
      Listener(
        child: Container(
          child: Column(children: [ListView(), ListView()],
        ),
      );
      

      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.

Copied title and URL