Last active
October 17, 2024 09:28
-
-
Save bambinoua/b1f67eaec2fe79a3d995aca938d423a4 to your computer and use it in GitHub Desktop.
Shapes with drag&drop
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 '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