Demonstrates how to interact with graphical items in a scene.
The Elastic Nodes example shows how to implement edges between nodes in a graph, with basic interaction. You can click to drag a node around, and zoom in and out using the mouse wheel or the keyboard. Hitting the space bar will randomize the nodes. The example is also resolution independent; as you zoom in, the graphics remain crisp.
Graphics View provides the QGraphicsScene class for managing and interacting with a large number of custom-made 2D graphical items derived from the QGraphicsItem class, and a QGraphicsView widget for visualizing the items, with support for zooming and rotation.
This example consists of a Node
class, an Edge
class, a GraphWidget
test, and a main
function: the Node
class represents draggable yellow nodes in a grid, the
Edge
class represents the lines between the nodes, the GraphWidget
class represents the application window, and the main()
function creates and shows this window, and runs the event
loop.
The Node
class serves three purposes:
Let's start by looking at the Node
class declaration.
class Node : public QGraphicsItem { public: Node(GraphWidget *graphWidget); void addEdge(Edge *edge); QList<Edge *> edges() const; enum { Type = UserType + 1 }; int type() const override { return Type; } void calculateForces(); bool advancePosition(); QRectF boundingRect() const override; QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; protected: QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; private: QList<Edge *> edgeList; QPointF newPos; GraphWidget *graph; };
The Node
class inherits QGraphicsItem, and reimplements the two mandatory functions boundingRect() and paint() to provide its visual appearance. It also reimplements shape() to ensure its hit area has an elliptic shape (as opposed to the default
bounding rectangle).
For edge management purposes, the node provides a simple API for adding edges to a node, and for listing all connected edges.
The advance() reimplementation is called whenever the scene's state advances by one step. The calculateForces() function is called to calculate the forces that push and pull on this node and its neighbors.
The Node
class also reimplements itemChange() to react to state changes (in this case, position changes), and mousePressEvent() and mouseReleaseEvent() to update the item's visual appearance.
We will start reviewing the Node
implementation by looking at its constructor:
Node::Node(GraphWidget *graphWidget) : graph(graphWidget) { setFlag(ItemIsMovable); setFlag(ItemSendsGeometryChanges); setCacheMode(DeviceCoordinateCache); setZValue(-1); }
In the constructor, we set the ItemIsMovable flag to allow the item to move in response to mouse dragging, and ItemSendsGeometryChanges to enable itemChange() notifications for position and transformation changes. We also enable DeviceCoordinateCache to speed up rendering performance. To ensure that the nodes are always stacked on top of edges, we finally set the item's Z value to -1.
Node
's constructor takes a GraphWidget
pointer and stores this as a member variable. We will revisit this pointer later on.
void Node::addEdge(Edge *edge) { edgeList << edge; edge->adjust(); } QList<Edge *> Node::edges() const { return edgeList; }
The addEdge() function adds the input edge to a list of attached edges. The edge is then adjusted so that the end points for the edge match the positions of the source and destination nodes.
The edges() function simply returns the list of attached edges.
void Node::calculateForces() { if (!scene() || scene()->mouseGrabberItem() == this) { newPos = pos(); return; }
There are two ways to move a node. The calculateForces()
function implements the elastic effect that pulls and pushes on nodes in the grid. In addition, the user can directly move one node around with the mouse.
Because we do not want the two approaches to operate at the same time on the same node, we start calculateForces()
by checking if this Node
is the current mouse grabber item (i.e., QGraphicsScene::mouseGrabberItem()). Because we need to find all neighboring (but not necessarily connected) nodes, we also make sure the item is part of a scene in the first
place.
// Sum up all forces pushing this item away qreal xvel = 0; qreal yvel = 0; const QList<QGraphicsItem *> items = scene()->items(); for (QGraphicsItem *item : items) { Node *node = qgraphicsitem_cast<Node *>(item); if (!node) continue; QPointF vec = mapToItem(node, 0, 0); qreal dx = vec.x(); qreal dy = vec.y(); double l = 2.0 * (dx * dx + dy * dy); if (l > 0) { xvel += (dx * 150.0) / l; yvel += (dy * 150.0) / l; } }
The "elastic" effect comes from an algorithm that applies pushing and pulling forces. The effect is impressive, and surprisingly simple to implement.
The algorithm has two steps: the first is to calculate the forces that push the nodes apart, and the second is to subtract the forces that pull the nodes together. First we need to find all the nodes in the graph. We call
QGraphicsScene::items() to find all items in the scene, and then use qgraphicsitem_cast() to look for Node
instances.
We make use of mapFromItem() to create a temporary vector pointing from this node to each other node, in local coordinates. We use the decomposed components of this vector to determine the direction and strength of force that should apply to the node. The forces
accumulate for each node, and are then adjusted so that the closest nodes are given the strongest force, with rapid degradation when distance increases. The sum of all forces is stored in xvel
(X-velocity) and
yvel
(Y-velocity).
// Now subtract all forces pulling items together double weight = (edgeList.size() + 1) * 10; for (const Edge *edge : qAsConst(edgeList)) { QPointF vec; if (edge->sourceNode() == this) vec = mapToItem(edge->destNode(), 0, 0); else vec = mapToItem(edge->sourceNode(), 0, 0); xvel -= vec.x() / weight; yvel -= vec.y() / weight; }
The edges between the nodes represent forces that pull the nodes together. By visiting each edge that is connected to this node, we can use a similar approach as above to find the direction and strength of all pulling
forces. These forces are subtracted from xvel
and yvel
.
if (qAbs(xvel) < 0.1 && qAbs(yvel) < 0.1) xvel = yvel = 0;
In theory, the sum of pushing and pulling forces should stabilize to precisely 0. In practice, however, they never do. To circumvent errors in numerical precision, we simply force the sum of forces to be 0 when they are less than 0.1.
QRectF sceneRect = scene()->sceneRect(); newPos = pos() + QPointF(xvel, yvel); newPos.setX(qMin(qMax(newPos.x(), sceneRect.left() + 10), sceneRect.right() - 10)); newPos.setY(qMin(qMax(newPos.y(), sceneRect.top() + 10), sceneRect.bottom() - 10)); }
The final step of calculateForces()
determines the node's new position. We add the force to the node's current position. We also make sure the new position stays inside of our defined boundaries. We don't
actually move the item in this function; that's done in a separate step, from advance()
.
bool Node::advancePosition() { if (newPos == pos()) return false; setPos(newPos); return true; }
The advance()
function updates the item's current position. It is called from GraphWidget::timerEvent()
. If the node's position changed, the function returns true; otherwise false is returned.
QRectF Node::boundingRect() const { qreal adjust = 2; return QRectF( -10 - adjust, -10 - adjust, 23 + adjust, 23 + adjust); }
The Node
's bounding rectangle is a 20x20 sized rectangle centered around its origin (0, 0), adjusted by 2 units in all directions to compensate for the node's outline stroke, and by 3 units down and to the right
to make room for a simple drop shadow.
QPainterPath Node::shape() const { QPainterPath path; path.addEllipse(-10, -10, 20, 20); return path; }
The shape is a simple ellipse. This ensures that you must click inside the node's elliptic shape in order to drag it around. You can test this effect by running the example, and zooming far in so that the nodes are very large. Without reimplementing shape(), the item's hit area would be identical to its bounding rectangle (i.e., rectangular).
void Node::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *) { painter->setPen(Qt::NoPen); painter->setBrush(Qt::darkGray); painter->drawEllipse(-7, -7, 20, 20); QRadialGradient gradient(-3, -3, 10); if (option->state & QStyle::State_Sunken) { gradient.setCenter(3, 3); gradient.setFocalPoint(3, 3); gradient.setColorAt(1, QColor(Qt::yellow).lighter(120)); gradient.setColorAt(0, QColor(Qt::darkYellow).lighter(120)); } else { gradient.setColorAt(0, Qt::yellow); gradient.setColorAt(1, Qt::darkYellow); } painter->setBrush(gradient); painter->setPen(QPen(Qt::black, 0)); painter->drawEllipse(-10, -10, 20, 20); }
This function implements the node's painting. We start by drawing a simple dark gray elliptic drop shadow at (-7, -7), that is, (3, 3) units down and to the right from the top-left corner (-10, -10) of the ellipse.
We then draw an ellipse with a radial gradient fill. This fill is either Qt::yellow to Qt::darkYellow when raised, or the opposite when sunken. In sunken state we also shift the center and focal point by (3, 3) to emphasize the impression that something has been pushed down.
Drawing filled ellipses with gradients can be quite slow, especially when using complex gradients such as QRadialGradient. This is why this example uses DeviceCoordinateCache, a simple yet effective measure that prevents unnecessary redrawing.
QVariant Node::itemChange(GraphicsItemChange change, const QVariant &value) { switch (change) { case ItemPositionHasChanged: for (Edge *edge : qAsConst(edgeList)) edge->adjust(); graph->itemMoved(); break; default: break; }; return QGraphicsItem::itemChange(change, value); }
We reimplement itemChange() to adjust the position of all connected edges, and to notify the scene that an item has moved (i.e., "something has happened"). This will trigger new force calculations.
This notification is the only reason why the nodes need to keep a pointer back to the GraphWidget
. Another approach could be to provide such notification using a signal; in such case, Node
would
need to inherit from QGraphicsObject.
void Node::mousePressEvent(QGraphicsSceneMouseEvent *event) { update(); QGraphicsItem::mousePressEvent(event); } void Node::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { update(); QGraphicsItem::mouseReleaseEvent(event); }
Because we have set the ItemIsMovable flag, we don't need to implement the logic that moves the node according to mouse input; this is already provided for us. We still need to reimplement the mouse press and release handlers, though, to update the nodes' visual appearance (i.e., sunken or raised).
The Edge
class represents the arrow-lines between the nodes in this example. The class is very simple: it maintains a source- and destination node pointer, and provides an adjust()
function that
makes sure the line starts at the position of the source, and ends at the position of the destination. The edges are the only items that change continuously as forces pull and push on the nodes.
Let's take a look at the class declaration:
class Edge : public QGraphicsItem { public: Edge(Node *sourceNode, Node *destNode); Node *sourceNode() const; Node *destNode() const; void adjust(); enum { Type = UserType + 2 }; int type() const override { return Type; } protected: QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; private: Node *source, *dest; QPointF sourcePoint; QPointF destPoint; qreal arrowSize = 10; };
Edge
inherits from QGraphicsItem, as it's a simple class that has no use for signals, slots, and properties (compare to QGraphicsObject).
The constructor takes two node pointers as input. Both pointers are mandatory in this example. We also provide get-functions for each node.
The adjust()
function repositions the edge, and the item also implements boundingRect() and paint().
We will now review its implementation.
Edge::Edge(Node *sourceNode, Node *destNode) : source(sourceNode), dest(destNode) { setAcceptedMouseButtons(Qt::NoButton); source->addEdge(this); dest->addEdge(this); adjust(); }
The Edge
constructor initializes its arrowSize
data member to 10 units; this determines the size of the arrow which is drawn in paint().
In the constructor body, we call setAcceptedMouseButtons(0). This ensures that the edge items are not considered for mouse input at all (i.e., you cannot click the
edges). Then, the source and destination pointers are updated, this edge is registered with each node, and we call adjust()
to update this edge's start end end position.
Node *Edge::sourceNode() const { return source; } Node *Edge::destNode() const { return dest; }
The source and destination get-functions simply return the respective pointers.
void Edge::adjust() { if (!source || !dest) return; QLineF line(mapFromItem(source, 0, 0), mapFromItem(dest, 0, 0)); qreal length = line.length(); prepareGeometryChange(); if (length > qreal(20.)) { QPointF edgeOffset((line.dx() * 10) / length, (line.dy() * 10) / length); sourcePoint = line.p1() + edgeOffset; destPoint = line.p2() - edgeOffset; } else { sourcePoint = destPoint = line.p1(); } }
In adjust()
, we define two points: sourcePoint
, and destPoint
, pointing at the source and destination nodes' origins respectively. Each point is calculated using local coordinates.
We want the tip of the edge's arrows to point to the exact outline of the nodes, as opposed to the center of the nodes. To find this point, we first decompose the vector pointing from the center of the source to the center of the destination node into X and Y, and then normalize the components by dividing by the length of the vector. This gives us an X and Y unit delta that, when multiplied by the radius of the node (which is 10), gives us the offset that must be added to one point of the edge, and subtracted from the other.
If the length of the vector is less than 20 (i.e., if two nodes overlap), then we fix the source and destination pointer at the center of the source node. In practice this case is very hard to reproduce manually, as the forces between the two nodes is then at its maximum.
It's important to notice that we call prepareGeometryChange() in this function. The reason is that the variables sourcePoint
and destPoint
are used directly when painting, and they are returned from the boundingRect() reimplementation. We must always call prepareGeometryChange() before changing what boundingRect() returns, and before these variables can be used by paint(), to keep Graphics View's internal bookkeeping clean. It's safest to call this function once, immediately before any such variable is modified.
QRectF Edge::boundingRect() const { if (!source || !dest) return QRectF(); qreal penWidth = 1; qreal extra = (penWidth + arrowSize) / 2.0; return QRectF(sourcePoint, QSizeF(destPoint.x() - sourcePoint.x(), destPoint.y() - sourcePoint.y())) .normalized() .adjusted(-extra, -extra, extra, extra); }
The edge's bounding rectangle is defined as the smallest rectangle that includes both the start and the end point of the edge. Because we draw an arrow on each edge, we also need to compensate by adjusting with half the arrow size and half the pen width in all directions. The pen is used to draw the outline of the arrow, and we can assume that half of the outline can be drawn outside of the arrow's area, and half will be drawn inside.
void Edge::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { if (!source || !dest) return; QLineF line(sourcePoint, destPoint); if (qFuzzyCompare(line.length(), qreal(0.))) return;
We start the reimplementation of paint() by checking a few preconditions. Firstly, if either the source or destination node is not set, then we return immediately; there is nothing to draw.
At the same time, we check if the length of the edge is approximately 0, and if it is, then we also return.
// Draw the line itself painter->setPen(QPen(Qt::black, 1, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter->drawLine(line);
We draw the line using a pen that has round joins and caps. If you run the example, zoom in and study the edge in detail, you will see that there are no sharp/square edges.
// Draw the arrows double angle = std::atan2(-line.dy(), line.dx()); QPointF sourceArrowP1 = sourcePoint + QPointF(sin(angle + M_PI / 3) * arrowSize, cos(angle + M_PI / 3) * arrowSize); QPointF sourceArrowP2 = sourcePoint + QPointF(sin(angle + M_PI - M_PI / 3) * arrowSize, cos(angle + M_PI - M_PI / 3) * arrowSize); QPointF destArrowP1 = destPoint + QPointF(sin(angle - M_PI / 3) * arrowSize, cos(angle - M_PI / 3) * arrowSize); QPointF destArrowP2 = destPoint + QPointF(sin(angle - M_PI + M_PI / 3) * arrowSize, cos(angle - M_PI + M_PI / 3) * arrowSize); painter->setBrush(Qt::black); painter->drawPolygon(QPolygonF() << line.p1() << sourceArrowP1 << sourceArrowP2); painter->drawPolygon(QPolygonF() << line.p2() << destArrowP1 << destArrowP2); }
We proceed to drawing one arrow at each end of the edge. Each arrow is drawn as a polygon with a black fill. The coordinates for the arrow are determined using simple trigonometry.
GraphWidget
is a subclass of QGraphicsView, which provides the main window with scrollbars.
class GraphWidget : public QGraphicsView { Q_OBJECT public: GraphWidget(QWidget *parent = nullptr); void itemMoved(); public slots: void shuffle(); void zoomIn(); void zoomOut(); protected: void keyPressEvent(QKeyEvent *event) override; void timerEvent(QTimerEvent *event) override; #if QT_CONFIG(wheelevent) void wheelEvent(QWheelEvent *event) override; #endif void drawBackground(QPainter *painter, const QRectF &rect) override; void scaleView(qreal scaleFactor); private: int timerId = 0; Node *centerNode; };
The class provides a basic constructor that initializes the scene, an itemMoved()
function to notify changes in the scene's node graph, a few event handlers, a reimplementation of drawBackground(), and a helper function for scaling the view by using the mouse wheel or keyboard.
GraphWidget::GraphWidget(QWidget *parent) : QGraphicsView(parent) { QGraphicsScene *scene = new QGraphicsScene(this); scene->setItemIndexMethod(QGraphicsScene::NoIndex); scene->setSceneRect(-200, -200, 400, 400); setScene(scene); setCacheMode(CacheBackground); setViewportUpdateMode(BoundingRectViewportUpdate); setRenderHint(QPainter::Antialiasing); setTransformationAnchor(AnchorUnderMouse); scale(qreal(0.8), qreal(0.8)); setMinimumSize(400, 400); setWindowTitle(tr("Elastic Nodes"));
GraphicsWidget
's constructor creates the scene, and because most items move around most of the time, it sets QGraphicsScene::NoIndex. The scene then
gets a fixed scene rectangle, and is assigned to the GraphWidget
view.
The view enables QGraphicsView::CacheBackground to cache rendering of its static, and somewhat complex, background. Because the graph renders a close collection of small items that all move around, it's unnecessary for Graphics View to waste time finding accurate update regions, so we set the QGraphicsView::BoundingRectViewportUpdate viewport update mode. The default would work fine, but this mode is noticeably faster for this example.
To improve rendering quality, we set QPainter::Antialiasing.
The transformation anchor decides how the view should scroll when you transform the view, or in our case, when we zoom in or out. We have chosen QGraphicsView::AnchorUnderMouse, which centers the view on the point under the mouse cursor. This makes it easy to zoom towards a point in the scene by moving the mouse over it, and then rolling the mouse wheel.
Finally we give the window a minimum size that matches the scene's default size, and set a suitable window title.
Node *node1 = new Node(this); Node *node2 = new Node(this); Node *node3 = new Node(this); Node *node4 = new Node(this); centerNode = new Node(this); Node *node6 = new Node(this); Node *node7 = new Node(this); Node *node8 = new Node(this); Node *node9 = new Node(this); scene->addItem(node1); scene->addItem(node2); scene->addItem(node3); scene->addItem(node4); scene->addItem(centerNode); scene->addItem(node6); scene->addItem(node7); scene->addItem(node8); scene->addItem(node9); scene->addItem(new Edge(node1, node2)); scene->addItem(new Edge(node2, node3)); scene->addItem(new Edge(node2, centerNode)); scene->addItem(new Edge(node3, node6)); scene->addItem(new Edge(node4, node1)); scene->addItem(new Edge(node4, centerNode)); scene->addItem(new Edge(centerNode, node6)); scene->addItem(new Edge(centerNode, node8)); scene->addItem(new Edge(node6, node9)); scene->addItem(new Edge(node7, node4)); scene->addItem(new Edge(node8, node7)); scene->addItem(new Edge(node9, node8)); node1->setPos(-50, -50); node2->setPos(0, -50); node3->setPos(50, -50); node4->setPos(-50, 0); centerNode->setPos(0, 0); node6->setPos(50, 0); node7->setPos(-50, 50); node8->setPos(0, 50); node9->setPos(50, 50); }
The last part of the constructor creates the grid of nodes and edges, and gives each node an initial position.
void GraphWidget::itemMoved() { if (!timerId) timerId = startTimer(1000 / 25); }
GraphWidget
is notified of node movement through this itemMoved()
function. Its job is simply to restart the main timer in case it's not running already. The timer is designed to stop when the graph
stabilizes, and start once it's unstable again.
void GraphWidget::keyPressEvent(QKeyEvent *event) { switch (event->key()) { case Qt::Key_Up: centerNode->moveBy(0, -20); break; case Qt::Key_Down: centerNode->moveBy(0, 20); break; case Qt::Key_Left: centerNode->moveBy(-20, 0); break; case Qt::Key_Right: centerNode->moveBy(20, 0); break; case Qt::Key_Plus: zoomIn(); break; case Qt::Key_Minus: zoomOut(); break; case Qt::Key_Space: case Qt::Key_Enter: shuffle(); break; default: QGraphicsView::keyPressEvent(event); } }
This is GraphWidget
's key event handler. The arrow keys move the center node around, the '+' and '-' keys zoom in and out by calling scaleView()
, and the enter and space keys randomize the positions
of the nodes. All other key events (e.g., page up and page down) are handled by QGraphicsView's default implementation.
void GraphWidget::timerEvent(QTimerEvent *event) { Q_UNUSED(event); QList<Node *> nodes; const QList<QGraphicsItem *> items = scene()->items(); for (QGraphicsItem *item : items) { if (Node *node = qgraphicsitem_cast<Node *>(item)) nodes << node; } for (Node *node : qAsConst(nodes)) node->calculateForces(); bool itemsMoved = false; for (Node *node : qAsConst(nodes)) { if (node->advancePosition()) itemsMoved = true; } if (!itemsMoved) { killTimer(timerId); timerId = 0; } }
The timer event handler's job is to run the whole force calculation machinery as a smooth animation. Each time the timer is triggered, the handler will find all nodes in the scene, and call
Node::calculateForces()
on each node, one at a time. Then, in a final step it will call Node::advance()
to move all nodes to their new positions. By checking the return value of advance()
,
we can decide if the grid stabilized (i.e., no nodes moved). If so, we can stop the timer.
void GraphWidget::wheelEvent(QWheelEvent *event) { scaleView(pow(2., -event->angleDelta().y() / 240.0)); }
In the wheel event handler, we convert the mouse wheel delta to a scale factor, and pass this factor to scaleView()
. This approach takes into account the speed that the wheel is rolled. The faster you roll the
mouse wheel, the faster the view will zoom.
void GraphWidget::drawBackground(QPainter *painter, const QRectF &rect) { Q_UNUSED(rect); // Shadow QRectF sceneRect = this->sceneRect(); QRectF rightShadow(sceneRect.right(), sceneRect.top() + 5, 5, sceneRect.height()); QRectF bottomShadow(sceneRect.left() + 5, sceneRect.bottom(), sceneRect.width(), 5); if (rightShadow.intersects(rect) || rightShadow.contains(rect)) painter->fillRect(rightShadow, Qt::darkGray); if (bottomShadow.intersects(rect) || bottomShadow.contains(rect)) painter->fillRect(bottomShadow, Qt::darkGray); // Fill QLinearGradient gradient(sceneRect.topLeft(), sceneRect.bottomRight()); gradient.setColorAt(0, Qt::white); gradient.setColorAt(1, Qt::lightGray); painter->fillRect(rect.intersected(sceneRect), gradient); painter->setBrush(Qt::NoBrush); painter->drawRect(sceneRect); // Text QRectF textRect(sceneRect.left() + 4, sceneRect.top() + 4, sceneRect.width() - 4, sceneRect.height() - 4); QString message(tr("Click and drag the nodes around, and zoom with the mouse " "wheel or the '+' and '-' keys")); QFont font = painter->font(); font.setBold(true); font.setPointSize(14); painter->setFont(font); painter->setPen(Qt::lightGray); painter->drawText(textRect.translated(2, 2), message); painter->setPen(Qt::black); painter->drawText(textRect, message); }
The view's background is rendered in a reimplementation of QGraphicsView::drawBackground(). We draw a large rectangle filled with a linear gradient, add a drop shadow, and then render text on top. The text is rendered twice for a simple drop-shadow effect.
This background rendering is quite expensive; this is why the view enables QGraphicsView::CacheBackground.
void GraphWidget::scaleView(qreal scaleFactor) { qreal factor = transform().scale(scaleFactor, scaleFactor).mapRect(QRectF(0, 0, 1, 1)).width(); if (factor < 0.07 || factor > 100) return; scale(scaleFactor, scaleFactor); }
The scaleView()
helper function checks that the scale factor stays within certain limits (i.e., you cannot zoom too far in nor too far out), and then applies this scale to the view.
In contrast to the complexity of the rest of this example, the main()
function is very simple: We create a QApplication instance, then create and show an instance of
GraphWidget
. Because all nodes in the grid are moved initially, the GraphWidget
timer will start immediately after control has returned to the event loop.