Skip to content

Instantly share code, notes, and snippets.

@sma
Last active January 28, 2024 23:15
Show Gist options
  • Save sma/78175a21620b27d18e8adc5e43019d53 to your computer and use it in GitHub Desktop.
Save sma/78175a21620b27d18e8adc5e43019d53 to your computer and use it in GitHub Desktop.
demonstrate draggable and resizable boxes in Flutter
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: Scaffold(body: Boxes()));
}
}
/// The "data model."
class Box {
Box(this.rect, this.color);
Rect rect;
Color color;
}
/// Displays a stack of boxes that can be moved around.
class Boxes extends StatefulWidget {
const Boxes({super.key});
@override
State<Boxes> createState() => _BoxesState();
}
class _BoxesState extends State<Boxes> {
/// The boxes to display.
final List<Box> _boxes = [
Box(const Rect.fromLTWH(10, 20, 160, 90), Colors.red),
Box(const Rect.fromLTWH(50, 60, 160, 90), Colors.orange),
Box(const Rect.fromLTWH(40, 90, 90, 160), Colors.pink),
];
/// Quasimode: Whether the next tap should add a box.
bool _nextTapIsAdd = false;
/// The currently selected box.
Box? _selectedBox;
void _beginMove(Box box) {
setState(() {
_nextTapIsAdd = false;
_selectedBox = box;
_boxes.remove(box);
_boxes.add(box);
});
}
void _moveBy(Box box, Offset delta) {
setState(() {
box.rect = box.rect.shift(delta);
});
}
void _beginAdd() {
setState(() {
_nextTapIsAdd = true;
_selectedBox = null;
});
}
void _add(Offset position) {
setState(() {
_boxes.add(
Box(
Rect.fromLTWH(position.dx - 45, position.dy - 45, 90, 90),
[
Colors.cyan,
Colors.blue,
Colors.indigo,
Colors.purple,
Colors.deepPurple,
Colors.deepOrange,
Colors.green,
Colors.lime,
Colors.teal,
Colors.yellow,
][Random().nextInt(10)],
),
);
_selectedBox = _boxes.last;
});
}
void _endAdd() {
setState(() => _nextTapIsAdd = false);
}
void _unselect() {
setState(() => _selectedBox = null);
}
void _delete() {
setState(() {
print(_selectedBox);
_boxes.remove(_selectedBox);
_selectedBox = null;
});
}
void _toBack() {
setState(() {
_boxes.remove(_selectedBox);
_boxes.insert(0, _selectedBox!);
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTapDown: _nextTapIsAdd ? (d) => _add(d.localPosition) : (_) => _unselect(),
onPanUpdate: _nextTapIsAdd ? (d) => _moveBy(_boxes.last, d.delta) : null,
onPanEnd: _nextTapIsAdd ? (_) => _endAdd() : null,
),
),
for (final box in _boxes)
Positioned.fromRect(
key: ValueKey(box),
rect: box.rect.inflate(Selection.inset),
child: GestureDetector(
onPanStart: (_) => _beginMove(box),
onPanUpdate: (details) => _moveBy(box, details.delta),
child: Selection(
active: box == _selectedBox,
rect: box.rect,
updateRect: (rect) => setState(() => box.rect = rect),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(1),
color: box.color,
),
),
),
),
),
Positioned.fill(
child: CustomPaint(
foregroundPainter: NodePainter(_boxes),
),
),
Align(
alignment: Alignment.bottomCenter,
child: ButtonBar(
alignment: MainAxisAlignment.start,
children: [
ElevatedButton(
onPressed: _nextTapIsAdd ? null : _beginAdd,
child: const Text('Add Box'),
),
ElevatedButton(
onPressed: _selectedBox == null ? null : _delete,
child: const Text('Remove Box'),
),
ElevatedButton(
onPressed: _selectedBox == null ? null : _toBack,
child: const Text('Send to back'),
),
],
),
),
],
);
}
}
/// Displays a selection around a [child] widget, needs an inset of 8.
class Selection extends StatelessWidget {
const Selection({
super.key,
required this.active,
required this.rect,
required this.updateRect,
required this.child,
this.color,
});
/// Whether the selection is active or not.
final bool active;
/// The rectangle to modify using `updateRect`.
final Rect rect;
/// Callback to update the rectangle.
final void Function(Rect) updateRect;
/// The child widget.
final Widget child;
/// The optional color of the selection.
final Color? color;
/// The inset of the selection, needed for [Positioned.fromRect].
static const inset = 8.0;
@override
Widget build(BuildContext context) {
if (!active) {
return Padding(
padding: const EdgeInsets.all(inset),
child: child,
);
}
final color = this.color ?? Colors.black54;
const borderWidth = 2.0;
const borderInset = 1.0;
const borderOffset = inset - borderInset - borderWidth;
const handleSize = inset + inset / 2;
return Stack(
children: [
Positioned.fill(
top: inset,
left: inset,
bottom: inset,
right: inset,
child: child,
),
Positioned(
top: handleSize,
left: borderOffset,
bottom: handleSize,
width: borderWidth,
child: ColoredBox(color: color),
),
Positioned(
top: handleSize,
right: borderOffset,
bottom: handleSize,
width: borderWidth,
child: ColoredBox(color: color),
),
Positioned(
top: borderOffset,
left: handleSize,
right: handleSize,
height: borderWidth,
child: ColoredBox(color: color),
),
Positioned(
bottom: borderOffset,
left: handleSize,
right: handleSize,
height: borderWidth,
child: ColoredBox(color: color),
),
Align(
alignment: Alignment.topLeft,
child: SelectionHandle(
onUpdate: (delta) => updateRect(
Rect.fromLTRB(rect.left + delta.dx, rect.top + delta.dy, rect.right, rect.bottom),
),
size: handleSize,
color: color,
),
),
Align(
alignment: Alignment.topRight,
child: SelectionHandle(
onUpdate: (delta) => updateRect(
Rect.fromLTRB(rect.left, rect.top + delta.dy, rect.right + delta.dx, rect.bottom),
),
size: handleSize,
color: color,
),
),
Align(
alignment: Alignment.bottomLeft,
child: SelectionHandle(
onUpdate: (delta) => updateRect(
Rect.fromLTRB(rect.left + delta.dx, rect.top, rect.right, rect.bottom + delta.dy),
),
size: handleSize,
color: color,
),
),
Align(
alignment: Alignment.bottomRight,
child: SelectionHandle(
onUpdate: (delta) => updateRect(
Rect.fromLTRB(rect.left, rect.top, rect.right + delta.dx, rect.bottom + delta.dy),
),
size: handleSize,
color: color,
),
),
// additional handles for resizing in one direction only
Align(
alignment: Alignment.centerLeft,
child: SelectionHandle(
onUpdate: (delta) => updateRect(Rect.fromLTRB(rect.left + delta.dx, rect.top, rect.right, rect.bottom)),
size: handleSize,
color: color,
),
),
Align(
alignment: Alignment.centerRight,
child: SelectionHandle(
onUpdate: (delta) => updateRect(Rect.fromLTRB(rect.left, rect.top, rect.right + delta.dx, rect.bottom)),
size: handleSize,
color: color,
),
),
Align(
alignment: Alignment.topCenter,
child: SelectionHandle(
onUpdate: (delta) => updateRect(Rect.fromLTRB(rect.left, rect.top + delta.dy, rect.right, rect.bottom)),
size: handleSize,
color: color,
),
),
Align(
alignment: Alignment.bottomCenter,
child: SelectionHandle(
onUpdate: (delta) => updateRect(Rect.fromLTRB(rect.left, rect.top, rect.right, rect.bottom + delta.dy)),
size: handleSize,
color: color,
),
),
],
);
}
}
/// A draggable handle, used by [Selection].
class SelectionHandle extends StatelessWidget {
const SelectionHandle({
super.key,
required this.onUpdate,
this.size = 16,
this.color,
this.borderRadius,
});
final void Function(Offset delta) onUpdate;
final double size;
final Color? color;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: (details) => onUpdate(details.delta),
child: DecoratedBox(
decoration: BoxDecoration(
color: color ?? Colors.black54,
borderRadius: borderRadius ?? BorderRadius.circular(1),
),
child: SizedBox(width: size, height: size),
),
);
}
}
class NodePainter extends CustomPainter {
const NodePainter(this.boxes);
final List<Box> boxes;
@override
void paint(Canvas canvas, Size size) {
final paint1 = Paint()
..color = Colors.black87
..strokeWidth = 3
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final paint2 = Paint()
..color = Colors.black87
..style = PaintingStyle.fill;
final src = boxes.firstOrNull;
if (src == null) return;
for (final dst in boxes.skip(1)) {
final i1 = intersect(src.rect, dst.rect, -1);
final i2 = intersect(dst.rect, src.rect, 2);
if (i1 == null || i2 == null) continue;
canvas.drawLine(i1, i2, paint1);
canvas.drawCircle(i1, 5, paint2);
}
}
@override
bool shouldRepaint(NodePainter oldDelegate) {
return !listEquals(oldDelegate.boxes, boxes);
}
/// Computes the intersection point at some edge of [src] for a line that
/// starts at the center of [src] and goes to the center of [dst]. Returns
/// `null` if there is no intersection.
Offset? intersect(Rect src, Rect dst, [double gap = 0]) {
final p1 = src.center;
final p2 = dst.center;
// if src and dst intersect, then there is no intersection, either
if (!src.intersect(dst).isEmpty) return null;
// compute the slope and intercept of the line from p1 to p2
final slope = (p2.dy - p1.dy) / (p2.dx - p1.dx);
final intercept = p1.dy - slope * p1.dx;
// to create a little gap
final src2 = src.inflate(gap);
// if p2 is to the right of p1, then the intersection is on the right edge
if (p1.dx < p2.dx) {
final y = slope * src2.right + intercept;
if (y >= src2.top && y <= src2.bottom) {
return Offset(src2.right, y);
}
} else {
final y = slope * src2.left + intercept;
if (y >= src2.top && y <= src2.bottom) {
return Offset(src2.left, y);
}
}
// if p2 is below p1, then the intersection is on the bottom edge
if (p1.dy < p2.dy) {
final x = (src2.bottom - intercept) / slope;
if (x >= src2.left && x <= src2.right) {
return Offset(x, src2.bottom);
}
} else {
final x = (src2.top - intercept) / slope;
if (x >= src2.left && x <= src2.right) {
return Offset(x, src2.top);
}
}
return null;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment