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);

9 comments:

  1. Hi,
    Thank you for the tutorial. Can you give tutorial to insert new row to the ListView by push button?
    Thanks

    ReplyDelete
    Replies
    1. Sure, it is a small piece of code. I can post it today or tomorrow.

      Delete
    2. Hi brother,
      I was reviewing my code to discover I was doing it in one line, like:
      model->appendRow(item);

      So, depending on your application, you just create whatever QStandardItem you want (and maybe save some data in database) then just add the item with that single line.

      In my case: I used to call a controller that manages saving my new item to the database then the controller emits a signal with the saved item and I handle it in my view like:
      connect(EntryController::getInstance(), SIGNAL(entryCreated(QStandardItem*)),
      SLOT(entryCreated(QStandardItem*)));

      and in the slot I add the item to the model.

      Delete
  2. Hi buddy, I have a question regarding your post.

    I follow the path you are suggesting here but I'm unable to wrap the content. it still consumes (and even exceeds) the rect.width(). Can it be related with a listview property?

    ReplyDelete
    Replies
    1. Yes it can. It happened with me while testing my code. Maybe you can check your listview properties for any changed values and reset them to defaults for testing.
      I can't remember the property that caused my problem, but I think it was a property for word wrap.

      Delete
  3. Thanks for code sharing, it is very useful to me.

    ReplyDelete
    Replies
    1. I'm glad I could help. Thank you for your comment.

      Delete
  4. Thanks for the code! One note: You should probably also be using if your text can be higher than the height of the viewport: `setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel)` Otherwise you won't be able to view certain portions of the text.

    ReplyDelete