Implementing chat type listview with text bubbles



  • I am trying to write a little program showing the chat history from a messaging app, trying to stay as close to the original look of the (android) app. The actual chat data is in a SQL database.

    At this moment I am creating a QSqlQueryModel, which holds the data, and a QListView which is supposed to show the data. In between I have a QStyledItemDelegate to do the painting in the view in order to get the custom look of the list items.

    An example of the look I am going for:

    0_1528469756400_example.png

    The main things being the bubble frame around the text, and the text having different sizes and possibly containing images (photos, gifs, emoji), video or audio. One of the main things I'm having trouble with, is that when drawing a bubble in the delegate, I can't seem to find out what size the contents are going to be. For normal text I might be able to use QFontMetrics, but with rich text I'm not sure. I tried putting everything in a QTextEdit since it handles rich text, and through the HTML capabilities also images. Then painting that QTextEdit in de delegates paint() method, but the QTextEdit doesn't seem to know its own size either (probably because its image is being painted, but the QTextEdit itself is never actually show()n).

    Am I on the right track with the model/view/delegate? I have also considered QGraphicsView, but I'm not sure if it would help with anything, and I think I really need the lazy loading of the view, since each chat can contain thousands of messages. Any hints on how to implement this? Even just starting getting this to work with messages containing only text would be a great help. Thanks!

    bepaald


  • Lifetime Qt Champion

    Hi,

    AFAIK, you're on the right track. I just stumbled upon this for HTML rendering. Might of interest.

    Hope it helps



  • Thank you very much! After your message I totally ran with it (and that example you provided helped a great deal), and got it mostly working. I have a few small problems still that I need to work out, but I think they would be better off in a new thread as they would be off topic here.

    Thanks again!



  • @bepaald said in Implementing chat type listview with text bubbles:

    Thank you very much! After your message I totally ran with it (and that example you provided helped a great deal), and got it mostly working. I have a few small problems still that I need to work out, but I think they would be better off in a new thread as they would be off topic here.

    Thanks again!

    I'm trying to create bubble chat for my desktop application. Can you please share how you end up implementing it? Would you mind sharing some of the code? Thanks!



  • @Taytoo said in Implementing chat type listview with text bubbles:

    @bepaald said in Implementing chat type listview with text bubbles:

    [...]

    I'm trying to create bubble chat for my desktop application. Can you please share how you end up implementing it? Would you mind sharing some of the code? Thanks!

    Hi Taytoo! As I said I totally ran with it and, after 7 months, the project is now huge (though still far from finished). As it is now sharing the code is impossible, it is thousands of lines of code and probably totally unreadable. I will try to come up with some minimal example in the near future and post it back here, but it will probably take a while as I am very busy.

    Just as a starting point, you might want to check out this link https://stackoverflow.com/questions/1956542/how-to-make-item-view-render-rich-html-text-in-qt/1956781#1956781, I think it was how I started as well.



  • @Taytoo
    Ok, I got around to creating a little example. It shows just a normal QListView, but painting the data in its model with a custom delegate. This delegate has two main functions: first it needs to report the size in the view an item is going to need to be painted. Second, it needs to paint.

    The main file just sets up some dummy data in a standard QStandardItemModel. Make sure you add any data to the model that your delegate might need to paint the item properly. In this example I only added an "outgoing" or "incoming" datafield to get some different messages, but you may need a ton more dataroles (I did!). Then it creates a pretty standard QListView. The only custom stuff is the ListViewDelegate set on the view using QListView::setItemDelegate().

    //main.cc
    
    #include <QtWidgets>
    
    #include "listviewdelegate.h"
    
    int main(int argc, char *argv[])
    {
      QApplication app(argc, argv);
    
      // create some data and put it in a model
      QStandardItemModel mymodel;
      QStandardItem *item1 = new QStandardItem("This is item one");
      item1->setData("Incoming", Qt::UserRole + 1);
      mymodel.appendRow(item1);
      QStandardItem *item2 = new QStandardItem("This is item two, it is a very long item, but it's not the item's fault, it is me typing all this text.");
      item2->setData("Outgoing", Qt::UserRole + 1);
      mymodel.appendRow(item2);
      QStandardItem *item3 = new QStandardItem("This is the third item");
      item3->setData("Incoming", Qt::UserRole + 1);
      mymodel.appendRow(item3);
    
      // create a view and set our data
      QListView listview;
      listview.setResizeMode(QListView::Adjust);
      listview.setWordWrap(true);
      listview.setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
      listview.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
      listview.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
      listview.setModel(&mymodel);
      listview.setMinimumSize(200,350);
    
      // NOW tell the view to rely on out custom delegate for drawing
      listview.setItemDelegate(new ListViewDelegate());
    
      // show it
      listview.show();
    
      return app.exec();
    }
    

    As said, the delegate just gives size hints and paints. You can paint anything you want in the items, just make sure to also update the sizeHint() for any new elements you're painting. I am using a QTextDocument to store the body text, as it can easily report its own size (especially its height given a certain width). Also, the HTML capabilities make it easy to add font styles and possibly inline images. In my own code I did end up implementing my own class (derived from QTextDocument) to support more content and work around an annoying bug.

    // listviewdelegate.h
    #ifndef LISTVIEWDELEGATE_H_
    #define LISTVIEWDELEGATE_H_
    
    #include <QAbstractItemDelegate>
    #include <QPainter>
    
    class ListViewDelegate : public QAbstractItemDelegate
    {
      int d_radius;
      int d_toppadding;
      int d_bottompadding;
      int d_leftpadding;
      int d_rightpadding;
      int d_verticalmargin;
      int d_horizontalmargin;
      int d_pointerwidth;
      int d_pointerheight;
      float d_widthfraction;
     public:
      inline ListViewDelegate(QObject *parent = nullptr);
     protected:
      inline void paint(QPainter *painter, QStyleOptionViewItem const &option, QModelIndex const &index) const;
      inline QSize sizeHint(QStyleOptionViewItem const &option, QModelIndex const &index) const;
    };
    
    inline ListViewDelegate::ListViewDelegate(QObject *parent)
      :
      QAbstractItemDelegate(parent),
      d_radius(5),
      d_toppadding(5),
      d_bottompadding(3),
      d_leftpadding(5),
      d_rightpadding(5),
      d_verticalmargin(15),
      d_horizontalmargin(10),
      d_pointerwidth(10),
      d_pointerheight(17),
      d_widthfraction(.7)
    {}
    
    inline void ListViewDelegate::paint(QPainter *painter, QStyleOptionViewItem const &option, QModelIndex const &index) const
    {
      QTextDocument bodydoc;
      QTextOption textOption(bodydoc.defaultTextOption());
      textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
      bodydoc.setDefaultTextOption(textOption);
      bodydoc.setDefaultFont(QFont("Roboto", 12));
      QString bodytext(index.data(Qt::DisplayRole).toString());
      bodydoc.setHtml(bodytext);
    
      qreal contentswidth = option.rect.width() * d_widthfraction - d_horizontalmargin - d_pointerwidth - d_leftpadding - d_rightpadding;
      bodydoc.setTextWidth(contentswidth);
      qreal bodyheight = bodydoc.size().height();
    
      bool outgoing = index.data(Qt::UserRole + 1).toString() == "Outgoing";
    
      painter->save();
      painter->setRenderHint(QPainter::Antialiasing);
    
      // uncomment to see the area provided to paint this item
      //painter->drawRect(option.rect);
    
      painter->translate(option.rect.left() + d_horizontalmargin, option.rect.top() + ((index.row() == 0) ? d_verticalmargin : 0));
    
      // background color for chat bubble
      QColor bgcolor("#DD1212");
      if (outgoing)
        bgcolor = "#DDDDDD";
    
      // create chat bubble
      QPainterPath pointie;
    
      // left bottom
      pointie.moveTo(0, bodyheight + d_toppadding + d_bottompadding);
    
      // right bottom
      pointie.lineTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - d_radius,
                     bodyheight + d_toppadding + d_bottompadding);
      pointie.arcTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - 2 * d_radius,
                    bodyheight + d_toppadding + d_bottompadding - 2 * d_radius,
                    2 * d_radius, 2 * d_radius, 270, 90);
    
      // right top
      pointie.lineTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding, 0 + d_radius);
      pointie.arcTo(0 + contentswidth + d_pointerwidth + d_leftpadding + d_rightpadding - 2 * d_radius, 0,
                    2 * d_radius, 2 * d_radius, 0, 90);
    
      // left top
      pointie.lineTo(0 + d_pointerwidth + d_radius, 0);
      pointie.arcTo(0 + d_pointerwidth, 0, 2 * d_radius, 2 * d_radius, 90, 90);
    
      // left bottom almost (here is the pointie)
      pointie.lineTo(0 + d_pointerwidth, bodyheight + d_toppadding + d_bottompadding - d_pointerheight);
      pointie.closeSubpath();
    
      // rotate bubble for outgoing messages
      if (outgoing)
      {
        painter->translate(option.rect.width() - pointie.boundingRect().width() - d_horizontalmargin - d_pointerwidth, 0);
        painter->translate(pointie.boundingRect().center());
        painter->rotate(180);
        painter->translate(-pointie.boundingRect().center());
      }
    
      // now paint it!
      painter->setPen(QPen(bgcolor));
      painter->drawPath(pointie);
      painter->fillPath(pointie, QBrush(bgcolor));
    
      // rotate back or painter is going to paint the text rotated...
      if (outgoing)
      {
        painter->translate(pointie.boundingRect().center());
        painter->rotate(-180);
        painter->translate(-pointie.boundingRect().center());
      }
    
      // set text color used to draw message body
      QAbstractTextDocumentLayout::PaintContext ctx;
      if (outgoing)
        ctx.palette.setColor(QPalette::Text, QColor("black"));
      else
        ctx.palette.setColor(QPalette::Text, QColor("white"));
    
      // draw body text
      painter->translate((outgoing ? 0 : d_pointerwidth) + d_leftpadding, 0);
      bodydoc.documentLayout()->draw(painter, ctx);
    
      painter->restore();
    }
    
    inline QSize ListViewDelegate::sizeHint(QStyleOptionViewItem const &option, QModelIndex const &index) const
    {
      QTextDocument bodydoc;
      QTextOption textOption(bodydoc.defaultTextOption());
      textOption.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
      bodydoc.setDefaultTextOption(textOption);
      bodydoc.setDefaultFont(QFont("Roboto", 12));
      QString bodytext(index.data(Qt::DisplayRole).toString());
      bodydoc.setHtml(bodytext);
    
      // the width of the contents are the (a fraction of the window width) minus (margins + padding + width of the bubble's tail)
      qreal contentswidth = option.rect.width() * d_widthfraction - d_horizontalmargin - d_pointerwidth - d_leftpadding - d_rightpadding;
    
      // set this available width on the text document
      bodydoc.setTextWidth(contentswidth);
    
      QSize size(bodydoc.idealWidth() + d_horizontalmargin + d_pointerwidth + d_leftpadding + d_rightpadding,
                 bodydoc.size().height() + d_bottompadding + d_toppadding + d_verticalmargin + 1); // I dont remember why +1, haha, might not be necessary
    
      if (index.row() == 0) // have extra margin at top of first item
        size += QSize(0, d_verticalmargin);
    
      return size;
    }
    
    #endif
    

    It should compile with

    qmake -project QT+=widgets
    qmake
    make
    


Looks like your connection to Qt Forum was lost, please wait while we try to reconnect.