Search code examples
flutterflutter-layoutflutter-animation

Dynamically layout widgets based on the size/position of other widgets (sub-widgets)


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:

enter image description here

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.

  • But how do I get the rendered size / dimension of a widget and recursively the dimension of a subtree?
  • And how do I get this dimensions when the code is about to place nodes / subtrees using Positioned on Stack?

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.


Solution

  • 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:

    enter image description here

    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