Skip to content

Instantly share code, notes, and snippets.

@Schwusch
Last active November 23, 2023 19:16
Show Gist options
  • Save Schwusch/7ac43eee6acb0c6ed2c8172cf4434cca to your computer and use it in GitHub Desktop.
Save Schwusch/7ac43eee6acb0c6ed2c8172cf4434cca to your computer and use it in GitHub Desktop.
blog_arrow_widget.dart
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