自定义的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;
};
View Code

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);
}
View Code

使用方法示例代码(参数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);
}

 

posted @ 2023-03-30 10:26  兜尼完  阅读(83)  评论(0编辑  收藏  举报