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:
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.