Hi! Hope you're enjoying this blog. I have a new home at www.goldsborough.me. Be sure to also check by there for new posts <3

Monday, March 9, 2015

Customizing QDials in Qt - Part 2

Welcome back to my tutorial on customizing QDials in Qt! In the first part of this tutorial I pointed out that while Qt gives us the possibility to style labels, buttons and many other widgets, there is no out-of-the-box way to customize QDials. I then explained that if we really do want to add some styling to a QDial, we have to subclass it and do a complete re-paint of the object. At the end of part one, you had this super awesome fully CSS-stylable dial:


That's a dial. We know that. Why? Because all dials look like that -- we've seen it 12000 times and now + 1. Let's innovate! Let's make this dial:



In this case, we're not sliding a small circle around the edge of a bigger one, we're actually drawing an arc with a length of between 0 and 360 degrees. Moreover, because the arc takes up relatively little space on the dial, I've added support for showing a title string as well as the dial value in the center of  our widget. Don't worry if you don't like the dial's background color, font color, arc color or the width of the arc, you'll have full support for customizing those options to your liking using stylesheets!

So, I guess I'll just throw the declaration of our QDial subclass -- CustomDial -- at you:

#ifndef CUSTOMDIAL_HPP
#define CUSTOMDIAL_HPP

#include <QDial>
#include <QString>
#include <QSharedPointer>

class QColor;
class QRectF;
class QPen;

class CustomDial : public QDial
{
    Q_OBJECT
    
    Q_PROPERTY(QString arcColor READ getArcColor WRITE setArcColor)
    
    Q_PROPERTY(double arcWidth READ getArcWidth WRITE setArcWidth)
    
public:
    
    explicit CustomDial(QWidget* parent = nullptr);
    
    CustomDial(const QString& text,
               QWidget * parent = nullptr,
               int minimum = 0,
               int maximum = 999);
    
    
    void setArcColor(const QString& color);
    
    QString getArcColor() const;
    
    
    void setStartAngle(double angle);
    
    double getStartAngle() const;
    
    
    void setMaximumAngle(double angle);
    
    double getMaximumAngle() const;
    
    
    void setArcWidth(double px);
    
    double getArcWidth() const;
    
    
    void setText(const QString& text);
    
    QString getText() const;
    
    
private slots:
    
    void updateValue();
    
private:
    
    virtual void paintEvent(QPaintEvent*) override;
    
    virtual void resizeEvent(QResizeEvent* event) override;
    
    double maximumAngleSpan_;
    
    double startAngle_;
    
    double arcWidth_;
    
    double angleSpan_;
    
    QString valueString_;
    
    QString text_;
    
    QSharedPointer arcRect_;
    
    QSharedPointer valueRect_;
    
    QSharedPointer textRect_;
    
    QSharedPointer arcColor_;
    
    QSharedPointer arcPen_;
};

#endif // CUSTOMDIAL_HPP

I'll talk about a few important aspects of this class. As in the last example, we subclass our CustomDial class from the QDial class, given that we want to retain its behavior -- the way you can slide it in a circular motion to change its value -- as well as its interface -- e.g. setRange(), setValue() or any size-related method.

By declaring a Q_PROPERTY for the color and width of the dial's arc, we will later be able to modify and customize those values from a stylesheet. Just to re-cap: the Q_PROPERTY macro takes a data type as its first argument, e.g. a QString for the color (an overload of QColor's constructor takes an RGB hex triplet as string), then a name for the property -- in our case arcColor and arcWidth, respectively -- followed by READ and WRITE member functions to retrieve the value and write to it in case it is changed in a stylesheet. Note that the two other properties we want to be able to customize -- the dial's background and font color -- already have property definitions built-in: you can  access them via the "background" and "color" properties in CSS.

The public methods of our CustomDial class are just interface -- getters and setters. Our private slot updateValue() we'll later on connect to the QDial::valueChanged signal so that we can update our internal variables, i.e. the span of the arc as well as the value string we display in the center of our dial. Note that we could technically calculate the arc span and other variables dependent on the dial's value in the paintEvent() method itself, however to keep things nice and optimized we take those invariants out, thus ensuring that we perform no unnecessary calculations should the dial value between two separate calls to paintEvent() not change.

I'll also quickly talk about the class' private members. paintEvent() is our familiar friend, who paints the widget onto our application's window. resizeEvent() is another inherited method from QDial, that is called automatically whenever the dial is forced to resize. Because we keep the painting areas / rectangles for our dial arc, dial title as well as the dial value string stored for optimization purposes, we'll have to resize those rectangles whenever the widget has to resize. That's what we do in resizeEvent().

startAngle is the angle at which the arc of our dial starts (270° in the dial shown above) and maximumAngleSpan is the maximum span the arc can have (-360° above). As such, the arc's angle span for any given dial value will be between startAngle (minimum dial value) and startAngle + maximumAngleSpan (maximum dial value). The other variables should be self-explanatory or will become so when I talk about them further.

Here is the definition of the CustomDial class:

#include "CustomDial.hpp"

#include <QPainter>
#include <QColor>
#include <QLabel>
#include <QRectf>
#include <QPen>
#include <QResizeEvent>

CustomDial::CustomDial(QWidget* parent)
: QDial(parent)
{ }

CustomDial::CustomDial(const QString& text,
                       QWidget* parent,
                       int minimum,
                       int maximum)
: QDial(parent),
  text_(text),
  arcRect_(new QRectF),
  valueRect_(new QRectF),
  textRect_(new QRectF),
  arcColor_(new QColor),
  arcPen_(new QPen)
{
    QDial::setRange(minimum, maximum);
    
    QDial::setCursor(Qt::PointingHandCursor);
    
    connect(this, &QDial::valueChanged,
            this, &CustomDial::updateValue);
    
    setMinimumSize(100,100);
    
    setMaximumAngle(-360);
    
    setStartAngle(270);
    
    updateValue();
}

CustomDial::~CustomDial() = default;

void CustomDial::paintEvent(QPaintEvent*)
{
    QPainter painter(this);
    
    // So that we can use the background color
    // Otherwise the background is transparent
    painter.setBackgroundMode(Qt::OpaqueMode);
    
    // Smooth out the circle
    painter.setRenderHint(QPainter::Antialiasing);
    
    // Use background color
    painter.setBrush(painter.background());
    
    // Get current pen before resetting so we have
    // access to the color() method which returns the
    // color from the stylesheet
    QPen textPen = painter.pen();
    
    // No border
    painter.setPen(QPen(Qt::NoPen));
    
    // Draw background circle
    painter.drawEllipse(QDial::rect());
    
    painter.setPen(textPen);
    
    painter.drawText(*textRect_, Qt::AlignHCenter | Qt::AlignBottom, text_);
    
    painter.drawText(*valueRect_, Qt::AlignCenter, valueString_);
    
    painter.setPen(*arcPen_);
    
    painter.drawArc(*arcRect_, startAngle_, angleSpan_);
    
}

void CustomDial::resizeEvent(QResizeEvent* event)
{
    QDial::setMinimumSize(event->size());
    
    double width = QDial::width() - (2 * arcWidth_);
    
    double height = width / 2;
    
    *textRect_ = QRectF(arcWidth_, arcWidth_, width, height);
    
    *valueRect_ = QRectF(arcWidth_, height, width, height);
    
    *arcRect_ = QRectF(arcWidth_ / 2,
                       arcWidth_ / 2,
                       QDial::width() - arcWidth_,
                       QDial::height() - arcWidth_);
}

void CustomDial::updateValue()
{
    double value = QDial::value();
    
    // Get ratio between current value and maximum to calculate angle
    double ratio = value / QDial::maximum();
    
    angleSpan_ = maximumAngleSpan_ * ratio;
    
    valueString_ = QString::number(value);
}

void CustomDial::setArcWidth(double px)
{
    arcWidth_ = px;
    
    *arcRect_ = QRectF(arcWidth_ / 2,
                       arcWidth_ / 2,
                       QDial::width() - arcWidth_,
                       QDial::height() - arcWidth_);
    
    arcPen_->setWidth(arcWidth_);
}

void CustomDial::setText(const QString& text)
{
    text_ = text;
}

QString CustomDial::getText() const
{
    return text_;
}

double CustomDial::getArcWidth() const
{
    return arcWidth_;
}

void CustomDial::setMaximumAngle(double angle)
{
    maximumAngleSpan_ = angle * 16;
}

double CustomDial::getMaximumAngle() const
{
    return maximumAngleSpan_ / 16;
}

void CustomDial::setStartAngle(double angle)
{
    startAngle_ = angle * 16;
}

double CustomDial::getStartAngle() const
{
    return startAngle_ / 16;
}

void CustomDial::setArcColor(const QString& color)
{
    arcColor_->setNamedColor(color);
    
    arcPen_->setColor(*arcColor_);
}

QString CustomDial::getArcColor() const
{
    return arcColor_->name();
}

A lot of this is just interface. A lot of it is not. But before I talk about anything that doesn't concern the getters and setters you see here, I just want to tell you why you see the number 16 floating around here. The reason why we have to multiply all angles we use by 16, such as the arc's start angle or angle span, is that we later on have to call QPainter::drawArc(), whose documentation reads:

void QPainter::drawArc ( const QRectF & rectangle, int startAngle, int spanAngle ) 

Draws the arc defined by the given rectangle, startAngle and spanAngle. 

The startAngle and spanAngle must be specified in 1/16th of a degree, i.e. a full circle equals 5760 (16 * 360). Positive values for the angles mean counter-clockwise while negative values mean the clockwise direction. Zero degrees is at the 3 o'clock position.

That should explain it. I'll talk about a few other non-self-explanatory methods.

In CustomDial::setColor(), we update the color of the arc by calling QColor::setNamedColor() on our internal arcColor_ variable and passing it the RGB hex triplet we get as a QString (e.g. #FFFFFF). We also set this color directly to our private arcPen_ variable which we'll later use to draw the arc in CustomDial::paintEvent().

CustomDial::setArcWidth() is the function responsible for, as the name implies, updating the width of the arc. When the width of the arc changes, we also have to update the rectangle in which we draw the arc later on in paintEvent(). The arc's dimensions are chosen in such a way that the arc sits right on the edge of the dial. Because the arc is essentially a line with a border of arcWidth_ / 2 to its left and right, we offset the position of the arc by arcWidth_ / 2 so that it fits onto the screen perfectly.

CustomDial::updateValue() is the private slot we connected to QDial::valueChanged so we can monitor the value of the dial and update our internal variables. In it, we calculate the ratio between the dial's current value and the maximum value (e.g. 50 / 100 = 0.5) and then calculate the current angleSpan of the arc as that ratio multiplied by the maximum angle span (e.g. 0.5 * -360° = -180°).
We also store the dial value as a string so we can display it later on.

CustomDial::resizeEvent() is the method we inherited from QDial, that is called whenever the widget is forced to resize. In it, we resize and update all our internally stored QRects (for the arc, for the dial value and the dial label / title / text). I should mention that the dial text / label occupies the top half of the dial and the dial value string the bottom half.

Lastly, the most important method: CustomDial::paintEvent(). It does the actual work -- the actual painting of our widget. I added some comments to the first part, the rest should be quite easy to understand. Basically, we first make the background color ("background" property in CSS) retrievable by setting the background mode to Qt::OpaqueMode, then set the QPainter object's current brush to that used for the background (which includes the background color). After ensuring that we get nicely drawn, anti-aliased circles via QPainter::setRenderHint(QPainter::Antialiasing), we store the current pen (which holds the color from the "color" CSS property), then set the pen to a borderless one and finally draw our background circle. We then reset the current pen to the stored one and so that we can draw the dial label and dial value string with the appropriate color. Lastly, we use the stored arcPen_ to draw the arc around the dial. You already saw the definition of QPainter::drawArc() above.

And that should make your CustomDial work! Of course, as promised, you can customize the dial using stylesheets. This is the CSS for the dial I showed at the top of this post:

CustomDial {
  background-color: transparent;
  font-size: 18px;
  font-family: DIN;
  color: #27272B;
  qproperty-arcColor: #27272B;
  qproperty-arcWidth: 3; }

Thanks for reading! I hope this helped! :)

3 comments :

  1. Works really well - nice work!

    2 minor suggestions:
    1. Move "#include " into the .hpp so your not relying on it being included else-where.
    2. 'qrectf' and 'QRectf' should be capitalised as 'QRectF'

    ReplyDelete
    Replies
    1. It seems that this code worked you! Can you please walk me through the process of getting it to work? I wasn't able to get it work. It crashes my program as soon as I have a QDial promoted to CustomDial.

      Would appreciate any help at this point! Been trying to get this to work for almost 2 weeks! :(

      Delete
  2. I would like to see a walkthrough of how to apply this to a PySide2 implementation. I have very little C++ knowledge (yet) and don't quite know how to implement this and then write a wrapper for it for use in PySide2 (or PyQt5).

    ReplyDelete