Last active
January 28, 2024 23:15
-
-
Save sma/78175a21620b27d18e8adc5e43019d53 to your computer and use it in GitHub Desktop.
demonstrate draggable and resizable boxes in Flutter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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