Skip to content

Instantly share code, notes, and snippets.

@bambinoua
Last active October 17, 2024 09:28
Show Gist options
  • Save bambinoua/b1f67eaec2fe79a3d995aca938d423a4 to your computer and use it in GitHub Desktop.
Save bambinoua/b1f67eaec2fe79a3d995aca938d423a4 to your computer and use it in GitHub Desktop.
Shapes with drag&drop
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class Foo5 extends StatefulWidget {
const Foo5({super.key, required this.image});
final ImageProvider image;
@override
State<Foo5> createState() => _Foo5State();
}
class _Foo5State extends State<Foo5> with TickerProviderStateMixin {
PolygonController?
activeController; // actively adding points to a new Polygon
PolygonController? selectedController;
final controllers = <PolygonController>[];
final transformationController = TransformationController();
Size size = Size.zero;
final hoverNotifier = ValueNotifier(Offset.zero);
final activeEdgeNotifier = ValueNotifier(-1);
late final morphingController =
AnimationController(vsync: this, duration: Durations.long4);
late final animationController =
AnimationController(vsync: this, duration: Durations.medium2);
ImageStream? _imageStream;
ui.Image? _image;
double get zoom => transformationController.value.getMaxScaleOnAxis();
@override
void didChangeDependencies() {
super.didChangeDependencies();
_getImage();
}
@override
void didUpdateWidget(Foo5 oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.image != oldWidget.image) {
_getImage();
}
}
void _getImage() {
final ImageStream? oldImageStream = _imageStream;
_imageStream = widget.image.resolve(createLocalImageConfiguration(context));
if (_imageStream!.key != oldImageStream?.key) {
final ImageStreamListener listener = ImageStreamListener(_updateImage);
oldImageStream?.removeListener(listener);
_imageStream!.addListener(listener);
}
}
void _updateImage(ImageInfo imageInfo, bool synchronousCall) {
print('_updateImage, imageInfo.image: ${imageInfo.image}');
setState(() {
_image = imageInfo.image;
});
}
@override
void dispose() {
_imageStream?.removeListener(ImageStreamListener(_updateImage));
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_image == null) return const UnconstrainedBox();
return Row(
children: [
SizedBox(
width: 100,
child: ListView(
children: List.generate(
10,
(i) => Draggable(
data: 'Drag $i',
feedback: Material(child: Chip(label: Text('Drag $i'))),
child: ListTile(
title: Text('Drag $i'),
),
),
)),
),
Expanded(
child: Column(
children: [
Text(
'click "new polygon" button and add at least 3 points on the grey area and you will see the shape you formed - it also has white round handlers that you can drag to reshape your polygon (notice that if you move your mouse over those handlers the mouse pointer changes its shape)\n\nyou can click "new polygon" button multiple times to add several polygons',
style: Theme.of(context).textTheme.bodySmall),
Row(
children: [
ElevatedButton(
onPressed: _addNewPolygon,
child: const Text('new polygon')),
ElevatedButton(
onPressed:
activeController != null ? _stopAddingPoints : null,
child: const Text('stop adding points')),
],
),
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
final imageSize =
Size(_image!.width.toDouble(), _image!.height.toDouble());
if (size != constraints.biggest) {
size = constraints.biggest;
transformationController.value =
sizeToRect(imageSize, Offset.zero & size);
}
return Listener(
onPointerHover: (e) =>
hoverNotifier.value = e.localPosition,
onPointerMove: (e) => hoverNotifier.value = e.localPosition,
child: ClipRect(
child: CustomMultiChildLayout(
delegate: FooDelegate(
transformationController: transformationController,
polygonController: selectedController,
),
children: [
LayoutId(
id: #interactiveViewer,
child: CustomPaint(
foregroundPainter: StackPainter(
repaint: Listenable.merge(
[hoverNotifier, morphingController]),
painters: [
OverlayPainter(
transformationController:
transformationController,
polygonController: activeController,
hoverNotifier: hoverNotifier,
morphingController: morphingController,
style:
Theme.of(context).textTheme.bodyMedium!,
),
GuidePaianter(
transformationController:
transformationController,
hoverNotifier: hoverNotifier,
style:
Theme.of(context).textTheme.bodySmall!,
),
],
),
child: InteractiveViewer(
constrained: false,
// minScale: 1,
// maxScale: 3,
transformationController:
transformationController,
child: SizedBox.fromSize(
size: imageSize,
child: CustomPaint(
painter: FloorPlanPainter(image: _image!),
child: ListenableBuilder(
listenable: selectedController ??
activeController ??
ValueNotifier(0),
builder: (ctx, child) {
return Stack(
children: [
...controllers.map(
(controller) => DragTarget(
onAcceptWithDetails:
(details) {
final box =
ctx.findRenderObject()
as RenderBox;
print(
'$box : ${box.paintBounds}');
final point1 =
box.globalToLocal(
details.offset);
final point2 =
transformationController
.toScene(point1);
final point3 = MatrixUtils
.transformPoint(
transformationController
.value,
point1);
print(
'$point1 : $point2 : $point3');
},
builder: (_, c, r) =>
AnimatedOpacity(
duration:
Durations.long2,
opacity: _opacity(
controller),
child: Polygon(
controller:
controller,
selected: selectedController ==
controller ||
activeController ==
controller,
onSelect:
(controller) {
if (selectedController ==
controller) {
return;
}
animationController
.forward(
from: 0);
setState(() =>
selectedController =
controller);
},
)),
),
),
if (activeController != null)
GestureDetector(
onTapDown: (d) {
final box =
ctx.findRenderObject()
as RenderBox;
print(
'$box : ${box.paintBounds}');
setState(() =>
activeController!.add(
d.localPosition));
morphingController.forward(
from: 0);
},
),
],
);
}),
),
),
),
),
),
if (selectedController != null)
..._makeControlPoints(),
],
),
),
);
}),
),
],
),
),
],
);
}
Iterable<Widget> _makeControlPoints() sync* {
final length = selectedController!.vertices.length;
final vertices = selectedController!.vertices;
print(vertices);
for (int i = 0; i < length; i++) {
yield LayoutId(
id: vertexBaseId + i,
child: ScaleTransition(
scale: animationController,
child: SizedBox.fromSize(
size: const Size.square(24),
child: Container(
decoration: const ShapeDecoration(
shape: CircleBorder(side: BorderSide(width: 1.5)),
color: Colors.white,
),
child: GestureDetector(
onPanUpdate: (d) =>
selectedController!.moveVertex(i, d.delta / zoom),
child: const MouseRegion(cursor: SystemMouseCursors.move),
),
),
),
),
);
var factor = 0.25;
yield LayoutId(
id: edgeBaseId + i,
key: UniqueKey(),
child: StatefulBuilder(
builder: (context, localSetState) {
return CustomPaint(
foregroundPainter: SplitEdgePainter(
edge: i,
activeEdgeNotifier: activeEdgeNotifier,
morphingController: morphingController,
),
child: ClipOval(
child: MouseRegion(
opaque: false,
onEnter: (d) {
if (selectedController?.vertices.firstOrNull != null) {
activeEdgeNotifier.value = i;
morphingController.forward(from: 0);
}
},
child: Padding(
padding: const EdgeInsets.all(12),
child: MouseRegion(
onEnter: (d) => localSetState(() => factor = 1),
onExit: (d) => localSetState(() => factor = 0.25),
// cursor: SystemMouseCursors.zoomIn,
child: AnimatedContainer(
duration: Durations.long2,
curve: Curves.ease,
width: 24 * factor,
height: 24 * factor,
clipBehavior: Clip.antiAlias,
decoration: const ShapeDecoration(
shape: CircleBorder(side: BorderSide(width: 1.5)),
color: Colors.deepPurple,
),
child: GestureDetector(
onTap: () {
setState(() {
final midPoint =
(vertices[i] + vertices[(i + 1) % length]) /
2;
selectedController!.insertVertex(i + 1, midPoint);
});
},
),
),
),
),
),
),
);
},
),
);
}
}
void _stopAddingPoints() {
setState(() => activeController = null);
}
void _addNewPolygon() {
setState(() {
selectedController = null;
controllers.add(activeController = PolygonController());
});
}
double _opacity(PolygonController controller) {
if (activeController == null) return 1;
return controller != activeController ? 0.25 : 0;
}
}
const vertexBaseId =
1 << 16; // we cannot have more than 2^16 vertices / edges per shape
const edgeBaseId = 2 << 16;
class FooDelegate extends MultiChildLayoutDelegate {
FooDelegate(
{required this.transformationController, required this.polygonController})
: super(
relayout: Listenable.merge(
[transformationController, polygonController]));
final TransformationController transformationController;
final PolygonController? polygonController;
@override
void performLayout(ui.Size size) {
// print('performLayout');
layoutChild(#interactiveViewer, BoxConstraints.tight(size));
if (polygonController != null) {
int i = 0;
int length = polygonController!.vertices.length;
final constraints = BoxConstraints.loose(size);
for (final vertex in polygonController!.vertices) {
var id = vertexBaseId + i;
var childSize = layoutChild(id, constraints);
positionChild(id, _transform(vertex) - childSize.center(Offset.zero));
id = edgeBaseId + i;
childSize = layoutChild(id, constraints);
final midPoint =
(vertex + polygonController!.vertices[(i + 1) % length]) / 2;
positionChild(id, _transform(midPoint) - childSize.center(Offset.zero));
i++;
}
}
}
Offset _transform(Offset o) =>
MatrixUtils.transformPoint(transformationController.value, o);
@override
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => true;
}
class FloorPlanPainter extends CustomPainter {
FloorPlanPainter({required this.image});
final ui.Image image;
@override
void paint(Canvas canvas, Size size) {
canvas.drawImage(image, Offset.zero, Paint());
}
@override
bool shouldRepaint(FloorPlanPainter oldDelegate) => false;
}
class StackPainter extends CustomPainter {
StackPainter({super.repaint, required this.painters});
final List<CustomPainter> painters;
@override
void paint(ui.Canvas canvas, ui.Size size) {
for (final painter in painters) {
canvas.save();
painter.paint(canvas, size);
canvas.restore();
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class OverlayPainter extends CustomPainter {
OverlayPainter({
required this.transformationController,
required this.polygonController,
required this.hoverNotifier,
required this.morphingController,
required this.style,
});
final TransformationController transformationController;
final PolygonController? polygonController;
final ValueNotifier<Offset> hoverNotifier;
final AnimationController morphingController;
final TextStyle style;
final linePaint = Paint()
..style = PaintingStyle.stroke
..color = Colors.black38
..strokeWidth = 1;
final fillPaint = Paint()..color = Colors.deepOrange;
@override
void paint(Canvas canvas, Size size) {
if (polygonController?.vertices.lastOrNull != null) {
final transformedVertices = transformationController.value
.transformPoints(polygonController!.vertices);
// draw the interior only when we have 3 or more points
if (polygonController!.vertices.length >= 3) {
final [...starting, last] = transformedVertices;
final middlePoint = (starting.first + starting.last) / 2;
final morphed = Offset.lerp(middlePoint, last,
Curves.ease.transform(morphingController.value))!;
final fillPath = Path()
..addPolygon(starting, false)
..lineTo(morphed.dx, morphed.dy)
..close();
canvas.drawPath(fillPath, fillPaint);
}
// draw the outline
final linePath = Path()
..addPolygon(transformedVertices, false)
..lineTo(hoverNotifier.value.dx, hoverNotifier.value.dy)
..close();
canvas.drawPath(linePath, linePaint);
final offset = hoverNotifier.value;
final line = transformedVertices.last - hoverNotifier.value;
final tp = TextPainter()
..textDirection = ui.TextDirection.ltr
..text = TextSpan(
text:
'''offset: ${offset.dx.toStringAsFixed(1)}, ${offset.dy.toStringAsFixed(1)}
length: ${line.distance.toStringAsFixed(1)}''',
style: style)
..layout();
final d = line.direction / pi;
final upsideDown = d < -0.5 || d > 0.5;
const pad = Offset(10, 2);
final rotation = upsideDown ? line.direction + pi : line.direction;
final matrix = composeMatrix(
rotation: rotation,
anchor: Offset(upsideDown ? pad.dx + tp.width : 0,
tp.height + 18 + linePaint.strokeWidth),
translate: hoverNotifier.value,
);
canvas.transform(matrix.storage);
BoxDecoration(
color: Colors.grey.shade300,
border: Border.all(width: 1, color: Colors.grey),
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
boxShadow: [
BoxShadow(
blurRadius: 4,
offset: Offset.fromDirection(0.25 * pi - rotation, 4))
],
)
.createBoxPainter()
.paint(canvas, Offset.zero, ImageConfiguration(size: tp.size + pad));
tp.paint(canvas, pad / 2);
}
}
@override
bool shouldRepaint(OverlayPainter oldDelegate) => false;
}
class GuidePaianter extends CustomPainter {
GuidePaianter({
required this.transformationController,
required this.hoverNotifier,
required this.style,
});
final TransformationController transformationController;
final ValueNotifier<Offset> hoverNotifier;
final TextStyle style;
final linePaint = Paint()
..style = PaintingStyle.stroke
..color = Colors.black38
..strokeWidth = 1;
final xLabel = BoxDecoration(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(6)),
color: const Color(0xffffff88),
border: Border.all(color: Colors.black45),
).createBoxPainter();
final yLabel = BoxDecoration(
borderRadius: const BorderRadius.horizontal(right: Radius.circular(6)),
color: const Color(0xffffff88),
border: Border.all(color: Colors.black45),
).createBoxPainter();
static const padding = Offset(4, 2);
@override
void paint(ui.Canvas canvas, ui.Size size) {
final hover = hoverNotifier.value;
canvas
..drawLine(Offset(0, hover.dy), Offset(size.width, hover.dy), linePaint)
..drawLine(Offset(hover.dx, 0), Offset(hover.dx, size.height), linePaint);
// final sceneHover = transformationController.toScene(hover);
// _drawLabel(canvas, sceneHover.dx, Offset(hover.dx, 0), hover, xLabel,
// Alignment.topCenter);
// _drawLabel(canvas, sceneHover.dy, Offset(0, hover.dy), hover, yLabel,
// Alignment.centerLeft);
}
void _drawLabel(Canvas canvas, double value, Offset baseOffset,
Offset transformedHover, BoxPainter labelPainter, Alignment alignment) {
final tp = TextPainter()
..textDirection = ui.TextDirection.ltr
..text = TextSpan(text: value.toStringAsFixed(1), style: style)
..layout();
final size = tp.size + padding * 2;
final offset = baseOffset - alignment.alongSize(size);
labelPainter.paint(canvas, offset, ImageConfiguration(size: size));
tp.paint(canvas, offset + padding);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class SplitEdgePainter extends CustomPainter {
SplitEdgePainter({
required this.edge,
required this.activeEdgeNotifier,
required this.morphingController,
});
final int edge;
final ValueNotifier<int> activeEdgeNotifier;
final AnimationController morphingController;
final linePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4;
@override
void paint(ui.Canvas canvas, ui.Size size) {
if (activeEdgeNotifier.value == edge) {
final center = size.center(Offset.zero);
var t = Curves.ease.transform(morphingController.value);
canvas.drawCircle(
center, 24 * t, linePaint..color = Colors.black.withOpacity(1 - t));
t = Curves.easeInOut.transform(morphingController.value);
canvas.drawCircle(
center, 24 * t, linePaint..color = Colors.black.withOpacity(1 - t));
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class FooShape extends ShapeBorder {
const FooShape(this.points);
final List<Offset> points;
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
throw UnimplementedError();
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
final path = Path()..addPolygon(points, true);
return path.shift(rect.topLeft);
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {}
@override
ShapeBorder scale(double t) => this;
}
class PolygonController extends ChangeNotifier {
final vertices = <Offset>[];
void add(Offset vertex) {
vertices.add(vertex);
notifyListeners();
}
void moveVertex(int i, Offset delta) {
vertices[i] += delta;
notifyListeners();
}
void move(Offset delta) {
for (int i = 0; i < vertices.length; i++) {
vertices[i] += delta;
}
notifyListeners();
}
void insertVertex(int i, Offset vertex) {
vertices.insert(i, vertex);
notifyListeners();
}
}
class Polygon extends StatelessWidget {
const Polygon({
super.key,
required this.controller,
required this.selected,
required this.onSelect,
});
final PolygonController controller;
final bool selected;
final Function(PolygonController controller) onSelect;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: selected ? Duration.zero : Durations.long2,
decoration: ShapeDecoration(
shape: FooShape(controller.vertices),
color: selected ? Colors.red : Colors.orange,
shadows: const [BoxShadow(blurRadius: 4, offset: Offset(2, 2))],
),
clipBehavior: Clip.antiAlias,
child: GestureDetector(
onPanDown: (d) => onSelect(controller),
onPanUpdate: (d) => controller.move(d.delta),
child: const MouseRegion(cursor: SystemMouseCursors.grab),
),
);
}
}
// =============================================================================
// =============================================================================
// =============================================================================
Matrix4 sizeToRect(Size src, Rect dst,
{BoxFit fit = BoxFit.contain, Alignment alignment = Alignment.center}) {
FittedSizes fs = applyBoxFit(fit, src, dst.size);
double scaleX = fs.destination.width / fs.source.width;
double scaleY = fs.destination.height / fs.source.height;
Size fittedSrc = Size(src.width * scaleX, src.height * scaleY);
Rect out = alignment.inscribe(fittedSrc, dst);
return Matrix4.identity()
..translate(out.left, out.top)
..scale(scaleX, scaleY);
}
Matrix4 composeMatrix({
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
}) {
final double c = cos(rotation) * scale;
final double s = sin(rotation) * scale;
final double dx = translate.dx - c * anchor.dx + s * anchor.dy;
final double dy = translate.dy - s * anchor.dx - c * anchor.dy;
return Matrix4(c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
}
extension TransformPoints on Matrix4 {
Offset transformPoint(Offset point) {
return MatrixUtils.transformPoint(this, point);
}
List<Offset> transformPoints(Iterable<Offset> points) {
return points.map((p) => MatrixUtils.transformPoint(this, p)).toList();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment