Monday, September 2, 2013

[Qt] Custom QListView delegate with word wrap

Using QListView with a custom item layout has been a very popular demand by many developers. The problem with using  a custom delegate was having some basic features like word wrap depending on the allowed list size.

Here is a screenshot from a project prototype I've been working on. The text will wrap around to fit in multiple lines depending on text length and list view width.


My custom delegate is about using two important methods: QFontMetrics::boundingRect() and QPainter::drawText() .

The first method will return the minimum Rect needed to draw the given text using the given origin and font. The second method draws the given text within the given Rect. Note that the flag Qt::TextWordWrap is given to both methods when called.

The code is simple:
  • Get system default font.
  • Process the font to suite your needs. In my case: I have a normal font and another bold one for the header.
  • Create a QFontMetrics instance for each font.
  • Use the QFontMetrics instances to calculate the Rect needed to draw that text given the needed flags and option.rect.width() that indicates the allowed width inside the list view.
  • Calculate the total area needed to return it in the sizeHint() method of your list view delegate. For example: I return the total heights of the two calculated rects -header and subheader- and the item width from option.rect.width(). And I prefer to add some extra padding.
  • Almost the same calculations apply in the paint() method of the list view delegate. Then the calculations are used in drawText().
Here is my ListVewDelegate class. It is a basic class with a basic drawing for header and subheader text. I left a space for drawing an icon. I also made a simple highlighting that needs more improvement and styling.

======
[listviewdelegate.h]
#ifndef LISTVIEWDELEGATE_H
#define LISTVIEWDELEGATE_H

#include <QStyledItemDelegate>
#include <QLabel>
#include <QPainter>
#include <QApplication>

class ListViewDelegate : public QStyledItemDelegate
{
public:
    enum datarole { HeaderRole = Qt::UserRole + 100, SubheaderRole};

    ListViewDelegate();
    ~ListViewDelegate();

    void paint(QPainter *painter,
               const QStyleOptionViewItem &option,
               const QModelIndex &index) const;

    QSize sizeHint(const QStyleOptionViewItem &option,
                   const QModelIndex &index ) const;

    static QSize iconSize;
    static int padding;
};

#endif // LISTVIEWDELEGATE_H

======
[listviewdelegate.cpp]
#include "listviewdelegate.h"

QSize ListViewDelegate::iconSize = QSize(60, 60);
int ListViewDelegate::padding = 5;

ListViewDelegate::ListViewDelegate()
{
}

ListViewDelegate::~ListViewDelegate()
{
}

QSize ListViewDelegate::sizeHint(const QStyleOptionViewItem &  option ,
                                        const QModelIndex & index) const
{
    if(!index.isValid())
        return QSize();

    QString headerText = index.data(HeaderRole).toString();
    QString subheaderText = index.data(SubheaderRole).toString();

    QFont headerFont = QApplication::font();
    headerFont.setBold(true);
    QFont subheaderFont = QApplication::font();
    QFontMetrics headerFm(headerFont);
    QFontMetrics subheaderFm(subheaderFont);

    /* No need for x,y here. we only need to calculate the height given the width.
     * Note that the given height is 0. That is because boundingRect() will return
     * the suitable height if the given geometry does not fit. And this is exactly
     * what we want.
     */
    QRect headerRect = headerFm.boundingRect(0, 0,
                                             option.rect.width() - iconSize.width(), 0,
                                             Qt::AlignLeft|Qt::AlignTop|Qt::TextWordWrap,
                                             headerText);
    QRect subheaderRect = subheaderFm.boundingRect(0, 0,
                                                   option.rect.width() - iconSize.width(), 0,
                                                   Qt::AlignLeft|Qt::AlignTop|Qt::TextWordWrap,
                                                   subheaderText);

    QSize size(option.rect.width(), headerRect.height() + subheaderRect.height() +  3*padding);

    /* Keep the minimum height needed in mind. */
    if(size.height()<iconSize.height())
        size.setHeight(iconSize.height());

    return size;
}

void ListViewDelegate::paint(QPainter *painter,
                                    const QStyleOptionViewItem &option,
                                    const QModelIndex &index) const
{
    if(!index.isValid())
        return;

    painter->save();

    if (option.state & QStyle::State_Selected)
        painter->fillRect(option.rect, option.palette.highlight());

    QString headerText = index.data(HeaderRole).toString();
    QString subheaderText = index.data(SubheaderRole).toString();

    QFont headerFont = QApplication::font();
    headerFont.setBold(true);
    QFont subheaderFont = QApplication::font();
    QFontMetrics headerFm(headerFont);
    QFontMetrics subheaderFm(subheaderFont);

    /*
     * The x,y coords are not (0,0) but values given by 'option'. So, calculate the
     * rects again given the x,y,w.
     * Note that the given height is 0. That is because boundingRect() will return
     * the suitable height if the given geometry does not fit. And this is exactly
     * what we want.
     */
    QRect headerRect =
            headerFm.boundingRect(option.rect.left() + iconSize.width(), option.rect.top() + padding,
                                  option.rect.width() - iconSize.width(), 0,
                                  Qt::AlignLeft|Qt::AlignTop|Qt::TextWordWrap,
                                  headerText);
    QRect subheaderRect =
            subheaderFm.boundingRect(headerRect.left(), headerRect.bottom()+padding,
                                     option.rect.width() - iconSize.width(), 0,
                                     Qt::AlignLeft|Qt::AlignTop|Qt::TextWordWrap,
                                     subheaderText);

    painter->setPen(Qt::black);

    painter->setFont(headerFont);
    painter->drawText(headerRect, Qt::AlignLeft|Qt::AlignTop|Qt::TextWordWrap, headerText);

    painter->setFont(subheaderFont);
    painter->drawText(subheaderRect, Qt::AlignLeft|Qt::AlignTop|Qt::TextWordWrap, subheaderText);

    painter->restore();
}

===========

Using it is pretty simple:

[mainwindow.cpp]
QStandardItemModel *model;  
model = new QStandardItemModel();  

ListViewDelegate *listdelegate;  
listdelegate = new ListViewDelegate(); 

ui->listView->setItemDelegate(listdelegate);  
ui->listView->setModel(model); 

QStandardItem *item = new QStandardItem();  
item->setData("Some Header",ListViewDelegate::HeaderRole);  
item->setData("Some long description text.",ListViewDelegate::SubheaderRole);  
model->appendRow(item);