Flutter Scrolling while dragging an item

eye-catchDart and Flutter
Sponsored links

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

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.

flutter_samples/scrollable_draggable.dart at main · yuto-yuto/flutter_samples
Contribute to yuto-yuto/flutter_samples development by creating an account on GitHub.

Comments

  1. Zvik says:

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

Copied title and URL