Flutter - Draggable Floating Action Button Example

This tutorial shows you how to create a draggable floating action button in Flutter.

Flutter allows you to add a floating action button using the FloatingActionButton widget. However, it doesn't allow you to drag the button. What if you want to make it draggable. This tutorial has an example that explains what you need to do in order to create a floating action button that can be dragged anywhere around the screen as long as it's within the parent widget.

Creating Draggable Floating Action Button

We are going to create a class for such a widget. The first thing we need to handle is the capability to make the button draggable following the pointer. One of the widgets that can be used is Listener, which is able to detect pointer move events and provide the movement detail. Basically, the button needs to be wrapped as the child of a Listener.

The Listener widget has onPointerMove argument which can be used to pass a callback that will be called when the pointer is moving. The callback function must have a parameter PointerMoveEvent which contains the movement delta in x and y directions (delta.dx and delta.dy). The offset of the button must be updated according to the movement delta.

A floating action button usually can perform an action when clicked, so we add a parameter called onPressed (VoidCallback) as a parameter. The Listener widget has onPointerUp argument which will be called when the user releases the pointer. Therefore, we can use it to pass a callback function that calls the onPressed callback. But you need to be careful. Usually, the desired behavior is that the onPressed callback is only called when the button is tapped, but not at the end of a drag. However, the pointer up event is also fired when a drag has ended. As a solution, we need to keep track of whether the button is being dragged. The _isDragging state variable is created for that purpose. It should be updated to true when the pointer is moved. So, we can check inside the onPointerUp callback to only call the onPressed callback if the value of _isDragging is false.

Below is the class for creating draggable floating action buttons. It has some arguments which include child (the widget to be set as the button), initialOffset (the initial offset before moved), and onPressed (the callback to be called when the button is clicked). The child widget is rendered using Positioned widget based on the current offset. It's also wrapped as the child of a Listener widget. There's also a method _updatePosition which updates the current offset based on the movement delta.

  class DraggableFloatingActionButton extends StatefulWidget {
  
    final Widget child;
    final Offset initialOffset;
    final VoidCallback onPressed;
  
    DraggableFloatingActionButton({
      required this.child,
      required this.initialOffset,
      required this.onPressed,
    });
  
    @override
    State<StatefulWidget> createState() => _DraggableFloatingActionButtonState();
  }
  
  class _DraggableFloatingActionButtonState extends State<DraggableFloatingActionButton> {
  
    bool _isDragging = false;
    late Offset _offset;
  
    @override
    void initState() {
      super.initState();
      _offset = widget.initialOffset;
    }
  
    void _updatePosition(PointerMoveEvent pointerMoveEvent) {
      double newOffsetX = _offset.dx + pointerMoveEvent.delta.dx;
      double newOffsetY = _offset.dy + pointerMoveEvent.delta.dy;

      setState(() {
        _offset = Offset(newOffsetX, newOffsetY);
      });
    }
  
    @override
    Widget build(BuildContext context) {
      return Positioned(
        left: _offset.dx,
        top: _offset.dy,
        child: Listener(
          onPointerMove: (PointerMoveEvent pointerMoveEvent) {
            _updatePosition(pointerMoveEvent);
  
            setState(() {
              _isDragging = true;
            });
          },
          onPointerUp: (PointerUpEvent pointerUpEvent) {
            print('onPointerUp');
  
            if (_isDragging) {
              setState(() {
                _isDragging = false;
              });
            } else {
              widget.onPressed();
            }
          },
          child: widget.child,
        ),
      );
    }
  }

Another thing that needs to be handled is preventing the floating action button from being out of the parent's box. If we ignore that, the user can drag the button outside the parent box. That means it's necessary to know the width and the height of the parent. You need to add a key to the parent widget and pass it to the DraggableFloatingActionButton widget. From the key, you can get the RenderBox from the currentContext property, which has findRenderObject method. Then, you can get the size of the parent from the size property of the RenderBox. You must be careful because the findRenderObject method must be called after the tree is built. Therefore, you need to invoke it using addPostFrameCallback of WidgetsBinding.

Having got the parent size, you can calculate the minimum and maximum offset in both horizontal and vertical axes. Not only the parent size, you also need to take account the button size for determining the maximum offset. Therefore, you need to do the similar thing for the child widget. For the child widget, it's possible to wrap it as the child of a Container and pass a GlobalKey to the Container.

The _updatePosition method needs to be adjusted too. If the new offset is lower than the minimum offset, the value has to be set to the minimum offset. If the new offset is greater than the maximum offset , the value has to be set to the maximum offset. You need to do that for both x and y axises.

  class DraggableFloatingActionButton extends StatefulWidget {
  
    final Widget child;
    final Offset initialOffset;
    final VoidCallback onPressed;
  
    DraggableFloatingActionButton({
      required this.child,
      required this.initialOffset,
      required this.onPressed,
    });
  
    @override
    State<StatefulWidget> createState() => _DraggableFloatingActionButtonState();
  }
  
  class _DraggableFloatingActionButtonState extends State<DraggableFloatingActionButton> {
  
    final GlobalKey _key = GlobalKey();
  
    bool _isDragging = false;
    late Offset _offset;
    late Offset _minOffset;
    late Offset _maxOffset;
  
    @override
    void initState() {
      super.initState();
      _offset = widget.initialOffset;
  
      WidgetsBinding.instance?.addPostFrameCallback(_setBoundary);
    }
  
    void _setBoundary(_) {
      final RenderBox parentRenderBox = widget.parentKey.currentContext?.findRenderObject() as RenderBox;
      final RenderBox renderBox = _key.currentContext?.findRenderObject() as RenderBox;
  
      try {
        final Size parentSize = parentRenderBox.size;
        final Size size = renderBox.size;
  
        setState(() {
          _minOffset = const Offset(0, 0);
          _maxOffset = Offset(
            parentSize.width - size.width,
            parentSize.height - size.height
          );
        });
      } catch (e) {
        print('catch: $e');
      }
    }
  
    void _updatePosition(PointerMoveEvent pointerMoveEvent) {
      double newOffsetX = _offset.dx + pointerMoveEvent.delta.dx;
      double newOffsetY = _offset.dy + pointerMoveEvent.delta.dy;
  
      if (newOffsetX < _minOffset.dx) {
        newOffsetX = _minOffset.dx;
      } else if (newOffsetX > _maxOffset.dx) {
        newOffsetX = _maxOffset.dx;
      }
  
      if (newOffsetY < _minOffset.dy) {
        newOffsetY = _minOffset.dy;
      } else if (newOffsetY > _maxOffset.dy) {
        newOffsetY = _maxOffset.dy;
      }
  
      setState(() {
        _offset = Offset(newOffsetX, newOffsetY);
      });
    }
  
    @override
    Widget build(BuildContext context) {
      return Positioned(
        left: _offset.dx,
        top: _offset.dy,
        child: Listener(
          onPointerMove: (PointerMoveEvent pointerMoveEvent) {
            _updatePosition(pointerMoveEvent);
  
            setState(() {
              _isDragging = true;
            });
          },
          onPointerUp: (PointerUpEvent pointerUpEvent) {
            print('onPointerUp');
  
            if (_isDragging) {
              setState(() {
                _isDragging = false;
              });
            } else {
              widget.onPressed();
            }
          },
          child: Container(
            key: _key,
            child: widget.child,
          ),
        ),
      );
    }
  }

Full Code

Below is the full code that uses the DraggableFloatingActionButton class above. A simple circle shaped widget is passed as the child argument which means it becomes the draggable button. You can use any widget for the button, including Flutter's FloatingActionButton widget.

  import 'package:flutter/material.dart';
  
  void main() => runApp(MyApp());
  
  class MyApp extends StatelessWidget {
  
    @override
    Widget build(BuildContext context) {
      return MaterialApp(
        title: 'Woolha.com Flutter Tutorial',
        home: DraggableFloatingActionButtonExample(),
      );
    }
  }
  
  class DraggableFloatingActionButtonExample extends StatelessWidget {
  
    final GlobalKey _parentKey = GlobalKey();
  
    @override
    Widget build(BuildContext context) {
      return new Scaffold(
        appBar: AppBar(
          title: const Text('Woolha.com Flutter Tutorial'),
          backgroundColor: Colors.teal,
        ),
        body: Column(
          children: [
            Container(
              height: 100,
            ),
            Container(
              width: 300,
              height: 300,
              child: Stack(
                key: _parentKey,
                children: [
                  Container(color: Colors.teal),
                  Center(
                    child: const Text(
                      'Woolha.com',
                      style: const TextStyle(color: Colors.white, fontSize: 24),
                    ),
                  ),
                  DraggableFloatingActionButton(
                    child: Container(
                      width: 50,
                      height: 50,
                      decoration: ShapeDecoration(
                        shape: CircleBorder(),
                        color: Colors.white,
                      ),
                      child: Icon(Icons.flutter_dash, color: Colors.blue, size: 50),
                    ),
                    initialOffset: const Offset(100, 100),
                    parentKey: _parentKey,
                    onPressed: () {
                      print('Button is clicked');
                    },
                  ),
                ],
              ),
            )
          ],
        ),
      );
    }
  }
  
  class DraggableFloatingActionButton extends StatefulWidget {
  
    final Widget child;
    final Offset initialOffset;
    final VoidCallback onPressed;
    final GlobalKey parentKey;
  
    DraggableFloatingActionButton({
      required this.child,
      required this.initialOffset,
      required this.onPressed,
      required this.parentKey,
    });
  
    @override
    State<StatefulWidget> createState() => _DraggableFloatingActionButtonState();
  }
  
  class _DraggableFloatingActionButtonState extends State<DraggableFloatingActionButton> {
  
    final GlobalKey _key = GlobalKey();
  
    bool _isDragging = false;
    late Offset _offset;
    late Offset _minOffset;
    late Offset _maxOffset;
  
    @override
    void initState() {
      super.initState();
      _offset = widget.initialOffset;
  
      WidgetsBinding.instance?.addPostFrameCallback(_setBoundary);
    }
  
    void _setBoundary(_) {
      final RenderBox parentRenderBox = widget.parentKey.currentContext?.findRenderObject() as RenderBox;
      final RenderBox renderBox = _key.currentContext?.findRenderObject() as RenderBox;
  
      try {
        final Size parentSize = parentRenderBox.size;
        final Size size = renderBox.size;
  
        setState(() {
          _minOffset = const Offset(0, 0);
          _maxOffset = Offset(
            parentSize.width - size.width,
            parentSize.height - size.height
          );
        });
      } catch (e) {
        print('catch: $e');
      }
    }
  
    void _updatePosition(PointerMoveEvent pointerMoveEvent) {
      double newOffsetX = _offset.dx + pointerMoveEvent.delta.dx;
      double newOffsetY = _offset.dy + pointerMoveEvent.delta.dy;
  
      if (newOffsetX < _minOffset.dx) {
        newOffsetX = _minOffset.dx;
      } else if (newOffsetX > _maxOffset.dx) {
        newOffsetX = _maxOffset.dx;
      }
  
      if (newOffsetY < _minOffset.dy) {
        newOffsetY = _minOffset.dy;
      } else if (newOffsetY > _maxOffset.dy) {
        newOffsetY = _maxOffset.dy;
      }
  
      setState(() {
        _offset = Offset(newOffsetX, newOffsetY);
      });
    }
  
    @override
    Widget build(BuildContext context) {
      return Positioned(
        left: _offset.dx,
        top: _offset.dy,
        child: Listener(
          onPointerMove: (PointerMoveEvent pointerMoveEvent) {
            _updatePosition(pointerMoveEvent);
  
            setState(() {
              _isDragging = true;
            });
          },
          onPointerUp: (PointerUpEvent pointerUpEvent) {
            print('onPointerUp');
  
            if (_isDragging) {
              setState(() {
                _isDragging = false;
              });
            } else {
              widget.onPressed();
            }
          },
          child: Container(
            key: _key,
            child: widget.child,
          ),
        ),
      );
    }
  }

Output:

Flutter - Draggable Floating Action Button

Summary

That's how to create a draggable floating action button in Flutter. Basically, you can use Listener widget to detect pointer move events and update the button offset based on the movement delta. The Listener widget also supports detecting pointer up events at which the button's action should be performed unless it has just been dragged. You also need to get the size of the parent and the button to prevent the button being out of the parent's box.