DEV Community

Cover image for Drawing arrows in Flutter, or anything else, based on widget positions
Jonathan Bรถcker for Charlie Foxtrot

Posted on • Updated on

Drawing arrows in Flutter, or anything else, based on widget positions

I recently made a Flutter package called widget_arrows which caught some attention on the FlutterDev subreddit.
I thought I'd take a minute to explain how it works, since the technique can be applied to other things, such as a tutorial hole overlay.

GitHub logo Schwusch / widget_arrows

Draw arrows between widgets in Flutter




Using it is fairly straight forward, wrap the Widgets you want to draw arrows between with an ArrowElement and wrap both with an ArrowContainer higher up in the Widget hierarchy .

In this case I wrap the whole MaterialApp in an ArrowContainer in order to display the arrows while dragging an element:

The core Widget is the ArrowContainer, which is the one drawing the arrows on a canvas layered on top of the child, using a Stack.
Here's an example:

ArrowContainer(
  child: Column(
    children: [
      ArrowElement(
        id: 'top',
        targetId: 'bottom',
        child: Text('Text1'),
      ),
      ArrowElement(
        show: showArrows,
        id: 'bottom',
        child: Text('Text2'),
      ),
    ],
  ),
)
Enter fullscreen mode Exit fullscreen mode

During initState(), the _ArrowElementState looks up the widget hierarchy and finds the closest _ArrowContainerState and registers itself by calling addArrow(this) on the container. The _ArrowContainerState will then trigger a repaint on the CustomPainter that draws on top of ArrowContainers child, which in this case is a Column.

How does the _ArrowElementState find the _ArrowContainerState? By calling this method on the BuildContext:

context.findAncestorStateOfType<_ArrowContainerState>();
Enter fullscreen mode Exit fullscreen mode

Since ArrowContainer is a StatefulWidget, it has its associated _ArrowContainerState. It's a relatively expensive lookup, but the good news is that it is only needed once per ArrowElement.

The _ArrowContainerState has ChangeNotifier mixed in, in its class declaration. That means that it can be passed in as an argument to the CustomPainter constructor, and it will repaint every time _ArrowContainerState calls notifyListeners().

_ArrowContainerState.notifyListeners() is called every time an arrow element is added or removed, which means it is always kept in sync with all ArrowElements currently mounted in its child Widget subtree.

The _ArrowContainerState build method look like this:

@override
Widget build(BuildContext context) => Stack(
      children: [
        widget.child,
        IgnorePointer(
          child: CustomPaint(
            foregroundPainter: _ArrowPainter(
              _elements,
              Directionality.of(context),
              this,
            ),
            child: Container(),
          ),
        ),
      ],
    );
Enter fullscreen mode Exit fullscreen mode

The CustomPaint is wrapped with an IgnorePointer, to let through touch events to widget.child, which is the users real UI. The _ArrowPainter is a custom class extending CustomPainter to draw the Arrows. It takes the _ArrowElementStates as a first argument, the TextDirection as the second argument (we will cover why later), and a Listenable as the third argument. The _ArrowContainerState is a Listenable, due to having ChangeNotifier mixed in.

What happens in _ArrowPainter.paint() is the real magic. It loops through all _ArrowElementStates, and calls this method:

final renderBox = arrowElementState
                    .context
                    .findRenderObject() as RenderBox;
Enter fullscreen mode Exit fullscreen mode

The RenderBox is the key to finding the position of the start and end of the arrows we're about to draw.
By calling this method:

final Offset upperLeftCorner = renderBox.localToGlobal(Offset.zero);
Enter fullscreen mode Exit fullscreen mode

We get an Offset of where it is relative to the top-left corner of the screen. The RenderBox also has RenderBox.size which gives us its dimensions, which is used to describe the rectangle in which the child is drawn.

This is where TextDirection comes in. The ArrowElement has a sourceAnchor and a targetAnchor argument which takes an AlignmentGeometry. This allows us to specify where in the child Widget we like the arrow to start or end from, and is Alignment.centerLeft by default. You can pass an AlignmentDirectional to make it adapt to whether the text is right-to-left or left-to-right. To adapt to the text direction, this method is called on the AlignmentGeometry object:

final Offset startPosition = sourceAnchor
  .resolve(textDirection)
  .withinRect(/*child widget rectangle*/);
Enter fullscreen mode Exit fullscreen mode

From there, all that's left is to draw a Path or whatever you like on the canvas at that position.

Happy Fluttering!

Top comments (1)

Collapse
 
33nano profile image
Manyong'oments

This is freaking awesome for visual learners