自定义的Qt聊天显示控件
这个控件写了好久,主要是因为控件的宽高跟它显示的文本图像内容的多少有关。一开始想用QLayout和sizeHint实现自动布局,试了好多次总是会有些问题。最终放弃了自动布局,采用手动布局。响应resizeEvent和QEvent::LayoutRequest消息,在内部计算每个消息的显示尺寸和位置。这个控件分为3级:MChat->MChatItem->MMixedRender。其中MChat负责总体的显示,它是一个滚动区域控件。MChatItem负责每一条消息的显示,它绘制头像和消息文本图像背景。MMixedRender负责绘制消息的内容(文本、超链接和图像),它自身的控件尺寸由消息内容决定。
目前的实现仅用于演示,不能实际运用。因为消息内容是用PaObject类表示,实际应用中的消息传递肯定用字节流(比如自定义二进制流、html、xml或json格式文本),而不是C++类。尽管如此,读者也可以从中学到一些东西。比如QNetworkAccessManager类的使用、自定义QAbstractScrollArea类等。注意:若要使用网络通信需要在Qt里添加network模块。并且可能需要libeay32.dll,ssleay32.dll。以下援引百度知道的回答:
在使用network模块的时候应该都会调用libeay32.dll和ssleay32.dll,之所以报错是因为可执行文件找不到这两个文件,但这两个文件确实存在于QT安装目录中的,把Qt安装目录下Qt5.5.1\Tools\QtCreator\bin\的libeay32.dll和ssleay32.dll复制到跟Qt5Network.dll同一个目录下。(或放在跟exe文件同一个目录中。)
这个控件在VS2017和Qt5.9上测试通过。其显示效果如下:
下面上代码,代码有500多行太多了,我默认把它们折叠起来。头文件:
class PaObject; class PaText; class PaLink; class PaImage; class PaObject { public: enum Type { TY_TEXT, TY_LINK, TY_IMAGE, }; public: virtual ~PaObject() = default; virtual Type type() const = 0; }; class PaText : public PaObject { public: PaText(const QString& s); Type type() const override { return PaObject::TY_TEXT; } QString string; QFont font; QPen color; }; class PaLink : public PaObject { public: PaLink(const QString& s, const QString& t); Type type() const override { return PaObject::TY_LINK; } QString string; QString target; QFont font; }; class PaImage : public PaObject { public: PaImage(const QString& filePath); PaImage(const QImage& iimage); Type type() const override { return PaObject::TY_IMAGE; } QImage image; QString url; bool resizable; }; class PaObjectManager : public QObject { Q_OBJECT public: PaObjectManager(QObject* parent = 0); ~PaObjectManager() override; void addItem(PaObject* o); void removeItem(PaObject* o); PaObject*& operator[](int index); PaObject* const & operator[](int index) const; public: using iterator = QVector<PaObject*>::iterator; using const_iterator = QVector<PaObject*>::const_iterator; iterator begin(); iterator end(); const_iterator begin() const; const_iterator end() const; signals: void dataChanged(PaObject* o); private slots: static void networkFinished(QNetworkReply* reply); private: struct NetworkContext { PaObjectManager* src; PaImage* target; }; private: static QNetworkAccessManager manager; static QMap<QNetworkReply*, NetworkContext> netAccess; static QMetaObject::Connection conn; QVector<PaObject*> objs; }; ///////////////////////////////////////////////////////////////////////////////////////// class MMixedRender : public QWidget { Q_OBJECT public: MMixedRender(QWidget* parent = 0); ~MMixedRender() override; void addItem(PaObject* po); QSize setDesiredSize(int maxw); signals: void linkActivated(const QString& link); private slots: void objectDataChanged(PaObject* obj); private: struct PosInfo { QRect rect; const PaObject* src; }; private: using PosInfoVecIterator = QVector<PosInfo>::iterator; void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void paintText(QPainter* painter, const PaText* textObj, PosInfoVecIterator& pos); void paintLink(QPainter* painter, const PaLink* linkObj, PosInfoVecIterator& pos); void paintImage(QPainter* painter, const PaImage* imageObj, PosInfoVecIterator& pos); void appendTextRect(const PaText* textObj, QVector<PosInfo>& rects) const; void appendLinkRect(const PaLink* linkObj, QVector<PosInfo>& rects) const; void appendImageRect(const PaImage* imageObj, int maxw, QVector<PosInfo>& rects) const; void updatePosInfo(int maxWidth, QVector<PosInfo>& infos) const; private: static const int largeInt; static const float lineHeight; PaObjectManager objs; QVector<PosInfo> regions; const PaLink* activeLink; QPoint mousePrPos; }; class MChatItem : public QWidget { Q_OBJECT public: MChatItem(bool alignLeft = true, QWidget* parent = 0); ~MChatItem() = default; void addItem(PaObject* po); int heightForWidth(int w) const override; signals: void linkActivated(const QString& link); private: void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; private: const static QSize headsz; const static QMargins msgPadd; const static QMargins contentPadd; bool isAlignLeft; MMixedRender* content; }; class MChat : public QAbstractScrollArea { Q_OBJECT public: MChat(QWidget* parent = 0); void addItem(MChatItem* item); private: bool viewportEvent(QEvent *event) override; bool eventFilter(QObject *object, QEvent *event) override; void changeScrollBar(); private slots: void vsbValueChanged(int value); private: const static int spacing; QWidget* content; };
CPP文件:
///////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////// PaText::PaText(const QString& s) : string(s), color(Qt::black) { } PaLink::PaLink(const QString& s, const QString& t) : string(s), target(t) { font.setUnderline(true); } PaImage::PaImage(const QString& filePath) : url(filePath), resizable(true) { } PaImage::PaImage(const QImage& iimage) : image(iimage), resizable(true) { } ///////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////// QNetworkAccessManager PaObjectManager::manager; QMap<QNetworkReply*, PaObjectManager::NetworkContext> PaObjectManager::netAccess; QMetaObject::Connection PaObjectManager::conn; PaObjectManager::PaObjectManager(QObject* parent) : QObject(parent) { // 首次使用初始化 // 因Qt信号槽必须在QApplication构造之后才能使用 if (!conn) { conn = connect(&manager, &QNetworkAccessManager::finished, this, &PaObjectManager::networkFinished); } } PaObjectManager::~PaObjectManager() { for (auto iter = netAccess.begin(); iter != netAccess.end();) { iter = (iter->src == this ? netAccess.erase(iter) : iter + 1); } qDeleteAll(objs); } void PaObjectManager::addItem(PaObject* o) { objs.append(o); if (o->type() == PaObject::TY_IMAGE) { PaImage* imageObj = dynamic_cast<PaImage*>(o); if (!imageObj->url.isEmpty()) { QUrl link = QUrl::fromUserInput(imageObj->url); if (link.isLocalFile()) { imageObj->image.load(imageObj->url); } else { // NOTE:网上说一个manager最多同时发起5个请求 // 太多可能会出问题 QNetworkRequest request(link); QNetworkReply* reply = manager.get(request); NetworkContext ctx; ctx.src = this; ctx.target = imageObj; netAccess.insert(reply, ctx); } } if (imageObj->image.isNull()) { imageObj->image = QImage(64, 64, QImage::Format_RGB888); QPainter painter(&imageObj->image); QRect rect = imageObj->image.rect(); painter.fillRect(rect, Qt::lightGray); painter.setPen(Qt::white); painter.drawText(rect, Qt::AlignCenter, u8"正在加载"); } } } void PaObjectManager::networkFinished(QNetworkReply* reply) { NetworkContext ctx = netAccess.value(reply, { 0, 0 }); if (!ctx.src) { // 此处可能进来 // 因网络请求完成前对象可被删除 return; } netAccess.remove(reply); ctx.target->image.load(reply, 0); emit ctx.src->dataChanged(ctx.target); reply->deleteLater(); } void PaObjectManager::removeItem(PaObject* o) { objs.removeOne(o); } PaObject*& PaObjectManager::operator[](int index) { return objs[index]; } PaObject* const & PaObjectManager::operator[](int index) const { return objs[index]; } PaObjectManager::iterator PaObjectManager::begin() { return objs.begin(); } PaObjectManager::iterator PaObjectManager::end() { return objs.end(); } PaObjectManager::const_iterator PaObjectManager::begin() const { return objs.begin(); } PaObjectManager::const_iterator PaObjectManager::end() const { return objs.end(); } ///////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////// const int MMixedRender::largeInt = 16777215; const float MMixedRender::lineHeight = 1.25f; /* 文字行高 */ MMixedRender::MMixedRender(QWidget* parent) : QWidget(parent), mousePrPos(-1, -1) { activeLink = 0; setMouseTracking(true); } MMixedRender::~MMixedRender() { } void MMixedRender::addItem(PaObject* po) { objs.addItem(po); connect(&objs, &PaObjectManager::dataChanged, this, &MMixedRender::objectDataChanged); } void MMixedRender::objectDataChanged(PaObject* obj) { parentWidget()->updateGeometry(); } void MMixedRender::appendTextRect(const PaText* textObj, QVector<PosInfo>& rects) const { QFontMetrics fm = QFontMetrics(textObj->font); for (auto ch : textObj->string) { QSize sz(largeInt, 0); if (ch != '\n') /* 换行符要特殊处理 */ { sz = fm.size(0, ch); } PosInfo info; info.rect = QRect(0, 0, sz.width(), sz.height() * lineHeight); info.src = textObj; rects.push_back(info); } } void MMixedRender::appendLinkRect(const PaLink* linkObj, QVector<PosInfo>& rects) const { QFontMetrics fm = QFontMetrics(linkObj->font); for (auto ch : linkObj->string) { QSize sz(largeInt, 0); if (ch != '\n') /* 换行符要特殊处理 */ { sz = fm.size(0, ch); } PosInfo info; info.rect = QRect(0, 0, sz.width(), sz.height() * lineHeight); info.src = linkObj; rects.push_back(info); } } void MMixedRender::appendImageRect(const PaImage* imageObj, int maxw, QVector<PosInfo>& rects) const { QSize sz = imageObj->image.size(); /* 对于可缩放的大图,将其缩放到适应控件宽度 */ if (sz.width() > maxw && imageObj->resizable) { sz.setHeight(sz.height() * maxw / sz.width()); sz.setWidth(maxw); } PosInfo info; info.rect = QRect(0, 0, sz.width(), sz.height()); info.src = imageObj; rects.push_back(info); } void MMixedRender::updatePosInfo(int maxWidth, QVector<PosInfo>& infos) const { for (auto item : objs) { switch (item->type()) { case PaObject::TY_TEXT: appendTextRect(dynamic_cast<PaText*>(item), infos); break; case PaObject::TY_LINK: appendLinkRect(dynamic_cast<PaLink*>(item), infos); break; case PaObject::TY_IMAGE: appendImageRect(dynamic_cast<PaImage*>(item), maxWidth, infos); break; default: throw std::exception("不支持的类型"); break; } } int count = infos.size(); int px = 0, py = 0; int a = 0, b = 0; while (b < count) { px = 0; for (int i = a; i < count; i++) { px += infos[i].rect.width(); if (px > maxWidth) { break; } b++; } if (b < count && infos[b].rect.width() == largeInt) /* 恢复特殊符号的大小 */ { infos[b].rect.setWidth(0); } if (b < count && a == b) { b++; /* 防止控件宽度过小,连一个字都放不下 */ } auto maxe = std::max_element(infos.begin() + a, infos.begin() + b, [](const PosInfo& a, const PosInfo& b) { return a.rect.height() < b.rect.height(); }); px = 0; for (int i = a; i < b; i++) { infos[i].rect.translate(px, py + maxe->rect.height() - infos[i].rect.height()); px += infos[i].rect.width(); } a = b; py += maxe->rect.height(); } } void MMixedRender::paintEvent(QPaintEvent * event) { QPainter painter(this); QVector<PosInfo>::iterator pos = regions.begin(); for (auto item : objs) { switch (item->type()) { case PaObject::TY_TEXT: paintText(&painter, dynamic_cast<PaText*>(item), pos); break; case PaObject::TY_LINK: paintLink(&painter, dynamic_cast<PaLink*>(item), pos); break; case PaObject::TY_IMAGE: paintImage(&painter, dynamic_cast<PaImage*>(item), pos); break; default: break; } } } void MMixedRender::paintText(QPainter* painter, const PaText* textObj, PosInfoVecIterator& pos) { painter->setFont(textObj->font); painter->setPen(textObj->color); for (auto ch : textObj->string) { painter->drawText(pos->rect, Qt::AlignVCenter, ch); pos++; } } void MMixedRender::paintLink(QPainter* painter, const PaLink* linkObj, PosInfoVecIterator& pos) { painter->setFont(linkObj->font); for (auto ch : linkObj->string) { painter->setPen(pos->src == activeLink ? qRgb(8, 127, 255) : qRgb(8, 8, 255)); painter->drawText(pos->rect, Qt::AlignVCenter, ch); pos++; } } void MMixedRender::paintImage(QPainter* painter, const PaImage* imageObj, PosInfoVecIterator& pos) { painter->drawImage(pos->rect, imageObj->image); pos++; } QSize MMixedRender::setDesiredSize(int maxw) { QSize prefer; regions.clear(); updatePosInfo(maxw, regions); if (!regions.empty()) { if (regions.first().rect.bottom() == regions.last().rect.bottom()) { prefer.setWidth(regions.last().rect.right()); prefer.setHeight(regions.last().rect.bottom()); } else { prefer.setWidth(maxw); prefer.setHeight(regions.last().rect.bottom()); } } resize(prefer); return prefer; } void MMixedRender::mousePressEvent(QMouseEvent *event) { mousePrPos = event->pos(); } void MMixedRender::mouseReleaseEvent(QMouseEvent *event) { if (mousePrPos == event->pos() && activeLink) { emit linkActivated(activeLink->target); } } void MMixedRender::mouseMoveEvent(QMouseEvent *event) { QPoint p = event->pos(); auto found = std::find_if(regions.begin(), regions.end(), [p](const PosInfo& x) { return x.rect.contains(p); }); const PaLink* text = 0; if (found != regions.end()) { text = dynamic_cast<const PaLink*>(found->src); } if (activeLink != text) { activeLink = text; update(); } if (activeLink) { setCursor(Qt::PointingHandCursor); setToolTip(u8"超链接:" + activeLink->target); } else { unsetCursor(); setToolTip(QString()); } } ///////////////////////////////////////////////////////////////////////////////////////// const QSize MChatItem::headsz(32, 32); /* 头像大小 */ const QMargins MChatItem::msgPadd(40, 0, 48, 0); /* 消息框边距 */ const QMargins MChatItem::contentPadd(8, 8, 8, 8); /* 消息文本边距 */ MChatItem::MChatItem(bool alignLeft, QWidget* parent) : QWidget(parent) { isAlignLeft = alignLeft; content = new MMixedRender(this); connect(content, &MMixedRender::linkActivated, this, &MChatItem::linkActivated); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); } void MChatItem::paintEvent(QPaintEvent *event) { QRect head, message; if (isAlignLeft) { head = QRect(QPoint(), headsz); } else { head = QRect(width() - headsz.width(), 0, headsz.width(), headsz.height()); } message = content->geometry().marginsAdded(contentPadd); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setPen(Qt::NoPen); painter.setBrush(Qt::gray); painter.drawEllipse(head); painter.setBrush(QColor(32, 200, 32)); painter.drawRoundedRect(message, 8, 8); } void MChatItem::addItem(PaObject* po) { content->addItem(po); } void MChatItem::resizeEvent(QResizeEvent *event) { int thisw = event->size().width(); int conw = content->size().width(); QPoint pos(0, msgPadd.top() + contentPadd.top()); if (isAlignLeft) { pos.setX(msgPadd.left() + contentPadd.left()); } else { pos.setX(thisw - msgPadd.left() - contentPadd.right() - conw); } content->move(pos); } int MChatItem::heightForWidth(int width) const { int w = qMax(24, width - msgPadd.left() - msgPadd.right() - contentPadd.left() - contentPadd.right()); QSize consz = content->setDesiredSize(w); int hfinal = qMax(headsz.height(), consz.height() + contentPadd.top() + contentPadd.bottom() + msgPadd.top() + msgPadd.bottom()); return hfinal; } ///////////////////////////////////////////////////////////////////////////////////////// const int MChat::spacing = 6; /* MChatItem之间的间距 */ MChat::MChat(QWidget* parent) : QAbstractScrollArea(parent) { content = new QWidget(viewport()); content->installEventFilter(this); QScrollBar *vsb = verticalScrollBar(); connect(vsb, &QScrollBar::valueChanged, this, &MChat::vsbValueChanged); } void MChat::addItem(MChatItem* item) { item->setParent(content); item->show(); /* 添加项之后立即更新控件大小 */ item->updateGeometry(); } void MChat::changeScrollBar() { int hcon = content->height(); int hview = viewport()->height(); QScrollBar *vsb = verticalScrollBar(); vsb->setRange(0, hcon - hview); vsb->setPageStep(hview); vsb->setSingleStep(10); } bool MChat::viewportEvent(QEvent *event) { if (event->type() == QEvent::Resize) { content->setFixedWidth(viewport()->width()); changeScrollBar(); } else if (event->type() == QEvent::Wheel) { QScrollBar *vsb = verticalScrollBar(); QApplication::sendEvent(vsb, event); } return false; } bool MChat::eventFilter(QObject *object, QEvent *event) { if (event->type() == QEvent::Resize) { changeScrollBar(); QResizeEvent* e = dynamic_cast<QResizeEvent*>(event); if (e->oldSize().width() != e->size().width()) { /* 如果宽度改变则重新计算控件高度 */ /* 给自己发一个重新布局消息 */ QApplication::postEvent(content, new QEvent(QEvent::LayoutRequest)); } } else if (event->type() == QEvent::LayoutRequest) { int width = content->width(); int dy = spacing; for (auto child : content->findChildren<MChatItem*>()) { int height = child->heightForWidth(width); child->setGeometry(0, dy, width, height); dy += height + spacing; } content->resize(width, dy); } return QAbstractScrollArea::eventFilter(object, event); } void MChat::vsbValueChanged(int value) { content->move(0, -value); }
使用方法示例代码(参数w是主窗口):
void test(QWidget* w) { MChat* chat = new MChat(w); chat->resize(800, 500); MChatItem* item1 = new MChatItem(true, 0); item1->addItem(new PaText(u8"sdadsdssa")); item1->addItem(new PaImage(u8"https://www.baidu.com/img/pcdoodle_2a77789e1a67227122be09c5be16fe46.png")); item1->addItem(new PaImage(u8R"(G:\Mr.Duu\ProjectM\QtTest\QtTest\1.bmp)")); item1->addItem(new PaText(u8"sda他地方\n撒范德萨dsdssa")); item1->addItem(new PaText(u8"sda他地方撒范德萨dsdssa")); item1->addItem(new PaLink(u8"sd地方撒a他萨dsdss范德a", u8"https://www.baidu.com")); item1->addItem(new PaText(u8"sda他地方撒范德萨dsdssa")); PaText* text = new PaText(u8"地方撒范德萨dsds地方撒范德萨dsds地方撒范德萨dsds"); text->font = QFont(u8"黑体", 22, -1, true); item1->addItem(text); item1->connect(item1, &MChatItem::linkActivated, [](const QString& str) { qDebug() << str; }); MChatItem* item2 = new MChatItem(false, 0); item2->addItem(new PaText(u8"十大的三个人头冈让二每天突然还不够发达")); item2->addItem(new PaText(u8"sgfdsddasdadsdsdssa")); item2->addItem(new PaText(u8"sgfdsddasssa")); item2->addItem(new PaText(u8"sadsdsdssa")); item2->addItem(new PaText(u8"asdadsdsdssa")); MChatItem* item3 = new MChatItem(true, 0); item3->addItem(new PaText(u8"我的")); chat->addItem(item1); chat->addItem(item2); chat->addItem(item3); }