Last active
November 23, 2023 19:16
-
-
Save Schwusch/7ac43eee6acb0c6ed2c8172cf4434cca to your computer and use it in GitHub Desktop.
blog_arrow_widget.dart
This file contains hidden or 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'; | |
class HomePage extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
body: Stack( | |
children: [ | |
Positioned( | |
left: 50, | |
top: 50, | |
child: Draggable( | |
childWhenDragging: Container(), | |
feedback: ArrowElement( | |
id: 'feedback', | |
targetId: 'target', | |
sourceAnchor: Alignment.centerRight, | |
child: | |
Container(height: 100, width: 100, color: Colors.orange), | |
), | |
child: ArrowElement( | |
id: 'draggable', | |
targetId: 'target', | |
flip: true, | |
sourceAnchor: Alignment.bottomCenter, | |
child: Container( | |
height: 100, | |
width: 100, | |
color: Colors.red, | |
child: const Center( | |
child: Text('Drag me'), | |
), | |
), | |
), | |
), | |
), | |
Positioned( | |
right: 50, | |
bottom: 50, | |
child: DragTarget<String>( | |
builder: (context, candidateData, rejectedData) => ArrowElement( | |
id: 'target', | |
child: Container( | |
height: 100, | |
width: 100, | |
color: Colors.green, | |
child: const Center( | |
child: Text( | |
'Drag target', | |
), | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
); | |
} | |
void main() => runApp(MyApp()); | |
class MyApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) => Directionality( | |
textDirection: TextDirection.ltr, | |
child: ArrowContainer( | |
child: MaterialApp( | |
home: HomePage(), | |
), | |
), | |
); | |
} | |
// ----------------- arrow lib below ------------------------------ | |
class ArrowContainer extends StatefulWidget { | |
final Widget child; | |
const ArrowContainer({super.key, required this.child}); | |
@override | |
ArrowContainerState createState() => ArrowContainerState(); | |
} | |
abstract class StatePatched<T extends StatefulWidget> extends State<T> { | |
void disposePatched() { | |
super.dispose(); | |
} | |
} | |
class ArrowContainerState extends StatePatched<ArrowContainer> | |
with ChangeNotifier { | |
final _elements = <String, ArrowElementState>{}; | |
@override | |
void dispose() { | |
super.dispose(); | |
disposePatched(); | |
} | |
@override | |
Widget build(BuildContext context) => Stack( | |
children: [ | |
widget.child, | |
IgnorePointer( | |
child: CustomPaint( | |
foregroundPainter: | |
_ArrowPainter(_elements, Directionality.of(context), this), | |
child: Container(), | |
), | |
), | |
], | |
); | |
void addArrow(ArrowElementState arrow) { | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
_elements[arrow.widget.id] = arrow; | |
notifyListeners(); | |
}); | |
} | |
void removeArrow(String id) { | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
if (mounted) { | |
_elements.remove(id); | |
notifyListeners(); | |
} | |
}); | |
} | |
} | |
class _ArrowPainter extends CustomPainter { | |
final Map<String, ArrowElementState> _elements; | |
final TextDirection _direction; | |
_ArrowPainter(this._elements, this._direction, Listenable repaint) | |
: super(repaint: repaint); | |
@override | |
void paint(Canvas canvas, Size size) => _elements.values.forEach((elem) { | |
final widget = elem.widget; | |
if (!widget.show) return; // don't show/paint | |
if (widget.targetId == null) { | |
return; // Unable to draw | |
} | |
if (_elements[widget.targetId] == null) { | |
print( | |
'cannot find target arrow element with id "${widget.targetId}"'); | |
return; | |
} | |
final start = elem.context.findRenderObject() as RenderBox; | |
final end = | |
_elements[widget.targetId]?.context.findRenderObject() as RenderBox; | |
if (!start.attached || !end.attached) { | |
print( | |
'one of "${widget.id}" or "${widget.targetId}" arrow elements render boxes is either not found or attached '); | |
return; // Unable to draw | |
} | |
final startGlobalOffset = start.localToGlobal(Offset.zero); | |
final endGlobalOffset = end.localToGlobal(Offset.zero); | |
final startPosition = widget.sourceAnchor | |
.resolve(_direction) | |
.withinRect(Rect.fromLTWH(startGlobalOffset.dx, | |
startGlobalOffset.dy, start.size.width, start.size.height)); | |
final endPosition = widget.targetAnchor.resolve(_direction).withinRect( | |
Rect.fromLTWH(endGlobalOffset.dx, endGlobalOffset.dy, | |
end.size.width, end.size.height)); | |
final paint = Paint() | |
..color = widget.color | |
..style = PaintingStyle.stroke | |
..strokeCap = StrokeCap.round | |
..strokeJoin = StrokeJoin.round | |
..strokeWidth = widget.width; | |
final arrow = getArrow( | |
startPosition.dx, | |
startPosition.dy, | |
endPosition.dx, | |
endPosition.dy, | |
bow: widget.bow, | |
stretch: widget.stretch, | |
stretchMin: widget.stretchMin, | |
stretchMax: widget.stretchMax, | |
padStart: widget.padStart, | |
padEnd: widget.padEnd, | |
straights: widget.straights, | |
flip: widget.flip, | |
); | |
final path = Path() | |
..moveTo(arrow.sx, arrow.sy) | |
..quadraticBezierTo(arrow.cx, arrow.cy, arrow.ex, arrow.ey); | |
final lastPathMetric = path.computeMetrics().last; | |
final firstPathMetric = path.computeMetrics().first; | |
var tan = lastPathMetric.getTangentForOffset(lastPathMetric.length); | |
if (tan == null) { | |
return; | |
} | |
var adjustmentAngle = 0.0; | |
final tipLength = widget.tipLength; | |
final tipAngleStart = widget.tipAngleOutwards; | |
final angleStart = pi - tipAngleStart; | |
final originalPosition = tan.position; | |
if (lastPathMetric.length > 10) { | |
final tanBefore = | |
lastPathMetric.getTangentForOffset(lastPathMetric.length - 5); | |
if (tanBefore == null) return; | |
adjustmentAngle = | |
_getAngleBetweenVectors(tan.vector, tanBefore.vector); | |
} | |
Offset tipVector; | |
tipVector = | |
_rotateVector(tan.vector, angleStart - adjustmentAngle) * tipLength; | |
path.moveTo(tan.position.dx, tan.position.dy); | |
path.relativeLineTo(tipVector.dx, tipVector.dy); | |
tipVector = _rotateVector(tan.vector, -angleStart - adjustmentAngle) * | |
tipLength; | |
path.moveTo(tan.position.dx, tan.position.dy); | |
path.relativeLineTo(tipVector.dx, tipVector.dy); | |
if (widget.doubleSided) { | |
tan = firstPathMetric.getTangentForOffset(0); | |
if (tan == null) return; | |
if (firstPathMetric.length > 10) { | |
final tanBefore = firstPathMetric.getTangentForOffset(5); | |
if (tanBefore == null) return; | |
adjustmentAngle = | |
_getAngleBetweenVectors(tan.vector, tanBefore.vector); | |
} | |
tipVector = _rotateVector(-tan.vector, angleStart - adjustmentAngle) * | |
tipLength; | |
path.moveTo(tan.position.dx, tan.position.dy); | |
path.relativeLineTo(tipVector.dx, tipVector.dy); | |
tipVector = | |
_rotateVector(-tan.vector, -angleStart - adjustmentAngle) * | |
tipLength; | |
path.moveTo(tan.position.dx, tan.position.dy); | |
path.relativeLineTo(tipVector.dx, tipVector.dy); | |
} | |
path.moveTo(originalPosition.dx, originalPosition.dy); | |
canvas.drawPath(path, paint); | |
}); | |
static Offset _rotateVector(Offset vector, double angle) => Offset( | |
cos(angle) * vector.dx - sin(angle) * vector.dy, | |
sin(angle) * vector.dx + cos(angle) * vector.dy, | |
); | |
static double _getVectorsDotProduct(Offset vector1, Offset vector2) => | |
vector1.dx * vector2.dx + vector1.dy * vector2.dy; | |
// Clamp to avoid rounding issues when the 2 vectors are equal. | |
static double _getAngleBetweenVectors(Offset vector1, Offset vector2) => | |
acos((_getVectorsDotProduct(vector1, vector2) / | |
(vector1.distance * vector2.distance)) | |
.clamp(-1.0, 1.0)); | |
@override | |
bool shouldRepaint(_ArrowPainter oldDelegate) => | |
!mapEquals(oldDelegate._elements, _elements) || | |
_direction != oldDelegate._direction; | |
} | |
class ArrowElement extends StatefulWidget { | |
/// Whether to show the arrow | |
final bool show; | |
/// ID for being targeted by other [ArrowElement]s | |
final String id; | |
/// The ID of the [ArrowElement] that will be drawn to | |
final String? targetId; | |
/// Where on the source Widget the arrow should start | |
final AlignmentGeometry sourceAnchor; | |
/// Where on the target Widget the arrow should end | |
final AlignmentGeometry targetAnchor; | |
/// A [Widget] to be drawn to or from | |
final Widget child; | |
/// Whether the arrow should be pointed both ways | |
final bool doubleSided; | |
/// Arrow color | |
final Color color; | |
/// Arrow width | |
final double width; | |
/// Length of arrow tip | |
final double tipLength; | |
/// Outwards angle of arrow tip, in radians | |
final double tipAngleOutwards; | |
/// A value representing the natural bow of the arrow. | |
/// At 0, all lines will be straight. | |
final double bow; | |
/// The length of the arrow where the line should be most stretched. Shorter | |
/// distances than 0 will have no additional effect on the bow of the arrow. | |
final double stretchMin; | |
/// The length of the arrow at which the stretch should have no effect. | |
final double stretchMax; | |
/// The effect that the arrow's length will have, relative to its minStretch | |
/// and maxStretch, on the bow of the arrow. At 0, the stretch will have no effect. | |
final double stretch; | |
/// How far the arrow's starting point should be from the provided start point. | |
final double padStart; | |
/// How far the arrow's ending point should be from the provided end point. | |
final double padEnd; | |
/// Whether to reflect the arrow's bow angle. | |
final bool flip; | |
/// Whether to use straight lines at 45 degree angles. | |
final bool straights; | |
const ArrowElement({ | |
super.key, | |
required this.id, | |
required this.child, | |
this.targetId, | |
this.show = true, | |
this.sourceAnchor = Alignment.centerLeft, | |
this.targetAnchor = Alignment.centerLeft, | |
this.doubleSided = false, | |
this.color = Colors.blue, | |
this.width = 3, | |
this.tipLength = 15, | |
this.tipAngleOutwards = pi * 0.2, | |
this.bow = 0.2, | |
this.stretchMin = 0, | |
this.stretchMax = 420, | |
this.stretch = 0.5, | |
this.padStart = 0, | |
this.padEnd = 0, | |
this.flip = false, | |
this.straights = true, | |
}); | |
@override | |
ArrowElementState createState() => ArrowElementState(); | |
} | |
class ArrowElementState extends State<ArrowElement> { | |
late ArrowContainerState _container; | |
@override | |
void initState() { | |
_container = context.findAncestorStateOfType<ArrowContainerState>()! | |
..addArrow(this); | |
super.initState(); | |
} | |
@override | |
void dispose() { | |
_container.removeArrow(widget.id); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) => widget.child; | |
} | |
Arrow getArrow( | |
double x0, | |
double y0, | |
double x1, | |
double y1, { | |
double bow = 0, | |
double stretchMin = 0, | |
double stretchMax = 420, | |
double stretch = 0.5, | |
double padStart = 0, | |
double padEnd = 0, | |
bool flip = false, | |
bool straights = true, | |
}) { | |
final angle = getAngle(x0, y0, x1, y1); | |
final dist = getDistance(x0, y0, x1, y1); | |
final angles = getAngliness(x0, y0, x1, y1); | |
// Step 0 ⤜⤏ Should the arrow be straight? | |
if (dist < (padStart + padEnd) * 2 || // Too short | |
(bow == 0 && stretch == 0) || // No bow, no stretch | |
(straights && | |
[0.0, 1.0, double.infinity].contains(angles)) // 45 degree angle | |
) { | |
// ⤜⤏ Arrow is straight! Just pad start and end points. | |
// Padding distances | |
final ps = max(0.0, min(dist - padStart, padStart)); | |
final pe = max(0.0, min(dist - ps, padEnd)); | |
// Move start point toward end point | |
var pp0 = projectPoint(x0, y0, angle, ps); | |
final px0 = pp0.first; | |
final py0 = pp0.last; | |
// Move end point toward start point | |
final pp1 = projectPoint(x1, y1, angle + pi, pe); | |
final px1 = pp1.first; | |
final py1 = pp1.last; | |
// Get midpoint between new points | |
final pb = getPointBetween(px0, py0, px1, py1); | |
final mx = pb.first; | |
final my = pb.last; | |
return Arrow(px0, py0, mx, my, px1, py1); | |
} | |
// ⤜⤏ Arrow is an arc! | |
// Is the arc clockwise or counterclockwise? | |
final rot = (getSector(angle) % 2 == 0 ? 1 : -1) * (flip ? -1 : 1); | |
// Calculate how much the line should "bow" away from center | |
final arc = | |
bow + mod(dist, [stretchMin, stretchMax], [1, 0], clamp: true) * stretch; | |
// Step 1 ⤜⤏ Find padded points. | |
// Get midpoint. | |
final mp = getPointBetween(x0, y0, x1, y1); | |
final mx = mp.first; | |
final my = mp.last; | |
// Get control point. | |
final cp = getPointBetween(x0, y0, x1, y1, d: 0.5 - arc); | |
var cx = cp.first; | |
var cy = cp.last; | |
// Rotate control point (clockwise or counterclockwise). | |
final rcp = rotatePoint(cx, cy, mx, my, (pi / 2) * rot); | |
cx = rcp.first; | |
cy = rcp.last; | |
// Get padded start point. | |
final a0 = getAngle(x0, y0, cx, cy); | |
final psp = projectPoint(x0, y0, a0, padStart); | |
final px0 = psp.first; | |
final py0 = psp.last; | |
// Get padded end point. | |
final a1 = getAngle(x1, y1, cx, cy); | |
final pep = projectPoint(x1, y1, a1, padEnd); | |
final px1 = pep.first; | |
final py1 = pep.last; | |
// Step 3 ⤜⤏ Find control point for padded points. | |
// Get midpoint between padded start / end points. | |
final pmp = getPointBetween(px0, py0, px1, py1); | |
final mx1 = pmp.first; | |
final my1 = pmp.last; | |
// Get control point for padded start / end points. | |
final pcp = getPointBetween(px0, py0, px1, py1, d: 0.5 - arc); | |
var cx1 = pcp.first; | |
var cy1 = pcp.last; | |
// Rotate control point (clockwise or counterclockwise). | |
final rpcp = rotatePoint(cx1, cy1, mx1, my1, (pi / 2) * rot); | |
cx1 = rpcp.first; | |
cy1 = rcp.last; | |
// Finally, average the two control points. | |
final acp = getPointBetween(cx, cy, cx1, cy1); | |
final cx2 = acp.first; | |
final cy2 = acp.last; | |
return Arrow(px0, py0, cx2, cy2, px1, py1); | |
} | |
class Arrow { | |
/// The x position of the (padded) starting point. | |
final double sx, | |
/// The y position of the (padded) starting point. | |
sy, | |
/// The x position of the (padded) control point. | |
cx, | |
/// The y position of the (padded) control point. | |
cy, | |
/// The x position of the (padded) ending point. | |
ex, | |
/// The y position of the (padded) ending point. | |
ey; | |
Arrow( | |
this.sx, | |
this.sy, | |
this.cx, | |
this.cy, | |
this.ex, | |
this.ey, | |
); | |
} | |
/// Modulate a value between two ranges | |
double mod(double value, List<double> a, List<double> b, {bool clamp = false}) { | |
final lh = b[0] < b[1] ? [b[0], b[1]] : [b[1], b[0]]; | |
final result = b[0] + ((value - a[0]) / (a[1] - b[0])) * (b[1] - b[0]); | |
if (clamp) { | |
if (result < lh.first) return lh.first; | |
if (result > lh.last) return lh.last; | |
} | |
return result; | |
} | |
/// Rotate a point around a center. | |
List<double> rotatePoint( | |
double x, double y, double cx, double cy, double angle) { | |
final s = sin(angle); | |
final c = cos(angle); | |
final px = x - cx; | |
final py = y - cy; | |
final nx = px * c - py * s; | |
final ny = px * s + py * c; | |
return [nx + cx, ny + cy]; | |
} | |
/// Get the distance between two points. | |
double getDistance(double x0, double y0, double x1, double y1) => | |
sqrt(pow(y1 - y0, 2) + pow(x1 - x0, 2)); | |
/// Get an angle (radians) between two points. | |
double getAngle(double x0, double y0, double x1, double y1) => | |
atan2(y1 - y0, x1 - x0); | |
/// Move a point in an angle by a distance. | |
List<double> projectPoint( | |
double x0, double y0, double angle, double distance) => | |
[cos(angle) * distance + x0, sin(angle) * distance + y0]; | |
/// Get a point between two points. | |
List<double> getPointBetween( | |
double x0, | |
double y0, | |
double x1, | |
double y1, { | |
double d = 0.5, | |
}) => | |
[x0 + (x1 - x0) * d, y0 + (y1 - y0) * d]; | |
/// Get the sector of an angle (e.g. quadrant, octant) | |
int getSector(double angle, {double doubleberOfSectors = 8}) => | |
(doubleberOfSectors * (0.5 + ((angle / (pi * 2)) % doubleberOfSectors))) | |
.floor(); | |
/// Get a normal value representing how close two points are from being at a 45 degree angle. | |
double getAngliness(double x0, double y0, double x1, double y1) => | |
((x1 - x0) / 2 / ((y1 - y0) / 2)).abs(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment