I'd like to draw a graph of nodes and edges. The graph should appear like a forest of trees. To simplify my question, let's focus on a tree, which should be drawn like this:
I'm not in search of the algorithm, which computes positions of nodes on the drawing plane. Recursively computing sizes and positions of visual representations of a nodes / subtrees using depth first tree traversal is trivial.
I'm in search of a flutter implementation:
I suppose, a Stack and Positioned widgets would be fine for placing nodes.
Could you please provide an example or a recipe?
Update 2022-09-25
As Randal Schwartz and PixelToast pointed out, boxy is a great solution to meet my current goal. Great solution created by @PixelToast!
Nevertheless, I'll keep the question open, in case someone posts details regarding the rendering / measuring process.
Unfortunately these kinds of layouts are not possible in Flutter without lots of boilerplate and a custom RenderObject. I am the author of the Boxy package which makes the process of creating one much simpler.
Here is a working solution:
class TreeNode {
const TreeNode(this.widget, [this.children = const []]);
final Widget widget;
final List<TreeNode> children;
Iterable<Widget> get allWidgets =>
[widget].followedBy(children.expand((e) => e.allWidgets));
}
class TreeView extends StatelessWidget {
const TreeView({
required this.root,
required this.verticalSpacing,
required this.horizontalSpacing,
super.key,
});
final TreeNode root;
final double verticalSpacing;
final double horizontalSpacing;
@override
Widget build(BuildContext context) {
return CustomBoxy(
delegate: _TreeViewBoxy(
root: root,
verticalSpacing: verticalSpacing,
horizontalSpacing: horizontalSpacing,
),
children: [...root.allWidgets],
);
}
}
class _TreeViewBoxy extends BoxyDelegate {
_TreeViewBoxy({
required this.root,
required this.verticalSpacing,
required this.horizontalSpacing,
});
final TreeNode root;
final double verticalSpacing;
final double horizontalSpacing;
@override
Size layout() {
var index = 0;
Size visit(TreeNode node, Offset offset) {
final nodeIndex = index++;
final child = children[nodeIndex];
final size = child.layout(const BoxConstraints());
final Size subtreeSize;
if (node.children.isEmpty) {
subtreeSize = size;
} else {
var width = 0.0;
var height = 0.0;
var x = 0.0;
final y = offset.dy + child.size.height + verticalSpacing;
for (final child in node.children) {
final childSize = visit(child, Offset(offset.dx + x, y));
height = max(height, childSize.height);
width += childSize.width;
x += childSize.width + horizontalSpacing;
}
width += (node.children.length - 1) * horizontalSpacing;
subtreeSize = Size(
max(width, size.width),
size.height + height + verticalSpacing,
);
}
child.position(
offset +
Offset(
subtreeSize.width / 2 - child.size.width / 2,
0,
),
);
return subtreeSize;
}
return visit(root, Offset.zero);
}
@override
void paint() {
var index = 0;
void paintLines(TreeNode node) {
final nodeOffset = children[index++].rect.bottomCenter;
for (final child in node.children) {
final childOffset = children[index].rect.topCenter;
canvas.drawPath(
Path()
..moveTo(nodeOffset.dx, nodeOffset.dy)
..cubicTo(
nodeOffset.dx,
nodeOffset.dy + verticalSpacing,
childOffset.dx,
childOffset.dy - verticalSpacing,
childOffset.dx,
childOffset.dy,
),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.0,
);
paintLines(child);
}
}
paintLines(root);
}
@override
bool shouldRelayout(_TreeViewBoxy oldDelegate) =>
root != oldDelegate.root ||
verticalSpacing != oldDelegate.verticalSpacing ||
horizontalSpacing != oldDelegate.horizontalSpacing;
}
The full example can be found here: https://gist.github.com/PixelToast/3739dee678ee1b19e4d299c0025794b9