QML—-仿WeChat界面功能搭建

搭设一个仿微信聊天界面的ui,实现自己给自己发消息,然后要复用组件,不涉及网络数据库

代码github,建议参考提交记录和文档,核心思路参考文章文章链接

file

界面搭设

全局颜色配置文件

新建一个ColorConfig

import QtQuick 2.13

QtObject{
    readonly property color strTitleColor: "#f5f5f5"
    readonly property color strInputColor: "red"

}

然后在main.qml里给她一个id,这样在main里边所有的文件都可以使用colorConfig来拿到全局颜色

Window {
    width: 700
    height: 720
    visible: true
    color: "#f5f5f5"
    ColorConfig {
        id: colorConfig
    }
    TitleItem{

    }
}

TitleItem
Rectangle{
    width:700
    height:80
    color: colorConfig.strInputColor

    Text{
        anchors.fill: parent
        x:0
        text: "这是标题"
        font.pixelSize: 30
    }

}

TitleItem.qml

要实现一个标题字,一个关闭窗口,拖动窗口这些功能把

拖拽窗口

将主窗口window传入item,记录一开始鼠标按下的位置,计算鼠标位置的偏移传递给窗口

property point dragStartPos: Qt.point(0, 0);

//拖拽窗口
    MouseArea{
        anchors.fill:parent;
        preventStealing: true;
        onPressed: {
            dragStartPos = Qt.point(mouse.x,mouse.y); //记录初始位置
        }

        onPositionChanged: {
            if(window){
                window.x += (mouse.x - dragStartPos.x);
                window.y += (mouse.y - dragStartPos.y);
            }
        }
    }

关闭窗口

去图标库下个图标,放到rectangle里就行.新建一个qtrecouse文件,把图标放入

file

考虑到这个关闭按钮有很多按钮都可以用到同一个样式,也把他分装为一个qml

IconButton.qml

Rectangle{
    id:btnRect
    width:30;
    height: 30
    color:colorConfig.strBtnBackgroundUnselect
    property string imageSource: ""

    Image{
        id:btnImage;
        width:24;
        height:24;
        anchors.centerIn: parent;
        source: imageSource;

        MouseArea{
            anchors.fill: parent;
            hoverEnabled: true; //启用鼠标悬浮检测
            onEntered: {
                btnRect.color = colorConfig.strBtnBackgroundSelect//鼠标进入背景变深
            }
            onExited: {
                btnRect.color = colorConfig.strBtnBackgroundUnselect //鼠标移开颜色变浅
            }
        }

    }
}

然后我们去TitleItem中调用这个IconButton,并设置位置和图标,还有关闭的功能

//关闭界面
    IconButton{
        anchors.top: parent.top;
        anchors.right: parent.right;
        imageSource: "qrc:/icon/ic_close.png"; //设置图标

        MouseArea{
            anchors.fill:parent;
            onClicked: {
                if(window){
                    window.close();
                }
            }
        }
    }

底部灰色线条

使用一个rectangle来生成线条

// 底部灰色线条
    Rectangle {
        height: 1; // 线条高度
        width: parent.width; // 与父容器同宽
        color: colorConfig.lightgray; // 浅灰色
        anchors.bottom: parent.bottom; // 固定在底部
    }

这样TitleItem就算搭建完了

file

ToolItem.qml

仿照微信的这一行工具栏进行搭设,下载几个图标,

file

使用model+repeater+我们之前定义的IconButton来重复生成多个按钮,中间使用一个rectangle把左右的按钮分开,效果还不错

Rectangle{
    width:700;
    height:40;
    color: colorConfig.weChatDefult;

    property string leftModel: "qrc:/icon/ic_expression.png,qrc:/icon/ic_file.png,qrc:/icon/ic_record.png";
    property string rightModel: "qrc:/icon/ic_camera.png";

    // 顶部灰色线条
    Rectangle {
        height: 1; // 线条高度
        width: parent.width; // 与父容器同宽
        color: colorConfig.lightgray; // 浅灰色
        anchors.bottom: parent.top; // 固定在底部
    }

    //左侧标签,文件,记录按钮
    Row{
        anchors.fill: parent
        spacing: 10
        anchors.margins: 5

        //左侧标签,文件,记录按钮
        Repeater{
            model:leftModel.split(",");
            delegate:IconButton{
                imageSource: modelData
            }
        }

        // 占位符,用于填充空白
        Rectangle {
            color: colorConfig.weChatDefult;
            width: parent.width - (leftModel.split(",").length + rightModel.split(",").length) * 30 - 20 * (leftModel.split(",").length + rightModel.split(",").length - 1)
            height: parent.height
        }

        Repeater{
            model:rightModel.split(",");
            delegate:IconButton{
                imageSource: modelData
            }
        }
    }

}

file

InputItem.qml

输入文本框,一开始使用TextEdit,发现不能滚动,后来查到资料使用scrollview和TextArea结合就行,一定要把换行打开,不然只会横向滚动

Rectangle{
    color: colorConfig.weChatDefult;
    width:700;
    height:150;

    //添加文字滚动效果
    ScrollView{
        id:view
        width:parent.width-20;
        height:parent.height-50;
        anchors.horizontalCenter: parent.horizontalCenter;
        anchors.top: parent.top;
        anchors.topMargin: 10;

        TextArea{
            id:textArea
            width: parent.width;
            font.pixelSize: 20;
            font.family: colorConfig.strTextFont;
            readOnly: false // 根据需要设置是否只读
            wrapMode: TextEdit.Wrap //开启换行
        }
    }

    //发送按钮
    Rectangle{
        id:sendBtn
        anchors.bottom: parent.bottom;
        anchors.bottomMargin: 10;
        anchors.right: parent.right;
        anchors.rightMargin: 30;

        width:80;
        height:40;
        color: "#e9e9e9"

        Text {
            anchors.centerIn: parent
            color:colorConfig.green;
            text: qsTr("发送")
            font.pixelSize: 20;
            font.family: colorConfig.strTextFont
        }

        MouseArea{
            anchors.fill:parent;
            hoverEnabled: true; //启用鼠标悬浮检测
            onClicked: {
                console.log("send");
            }
            onEntered: {
                sendBtn.color = "#d2d2d2";
            }
            onExited: {
                sendBtn.color = "#e9e9e9";
            }

        }
    }
}

ChatContentItem.qml

核心,聊天内容展示,没啥思路看看网上的咋写的,参考一下这个 文章链接,作者写的很好基本可以直接照搬了,大佬写的太好了,核心思路

两个头像和聊天内容区为一块,作为listview展示的内容,以聊天内容的高度约束整个item,判断左右侧头像的显示.当listmodel收到聊天内容时就创建这样的一个Item作为展示.

file

main.qml

去掉原来的最小化,关闭的标题栏,挺丑的,加上 flags:Qt.FramelessWindowHint

Listmodel

首先通过枚举定义消息类型,一条消息发送时候包含的信息,如发送者,接受者,内容等等.TalkListDefine.h

这段代码的主要作用是为聊天应用定义一个通用的数据结构,用于在 QML 和 C++ 之间传递消息数据。通过将枚举和结构体暴露给 QML,你可以在 QML 中方便地使用这些数据类型和状态来管理和展示聊天消息。

#ifndef TALKLISTDEFINE_H
#define TALKLISTDEFINE_H

#include <QObject>

//将枚举注册到QML
class TalkData : public QObject
{
    Q_OBJECT
public:
    using QObject::QObject;

    //数据类型
    enum DataType
    {
        Text,   //文本
        Image,  //图片
        Audio,  //语音
        Video   //视频
    };
    Q_ENUM(DataType);

    //数据处理状态
    enum DataStatus
    {
        DataError,  //无效数据
        DataReady,  //代解析
        ParseOn,    //解析成功
        ParseSuccess,   //解析成功
        ParseError      //解析失败
    };
    Q_ENUM(DataStatus);
};

//消息基类
struct TalkDataBasic
{
    qint64 id;          //消息id
    QString user;       //消息所属用户
    QString sender;     //发送者
    qint64 datetime;    //发送时间 ms
    //消息类型:文本,语音,图片,视频
    TalkData::DataType type = TalkData::Text;
    //处理状态
    TalkData::DataStatus status = TalkData::DataError;
};

//文本消息
struct TalkDataText : public TalkDataBasic
{
    QString text;//文本信息
};

#endif // TALKLISTDEFINE_H

新建TalkListModel文件,来储存我们的数据,ListModel用过好几次了,需要重写三个函数 rowCount(),data(),roleNames(),一个清空模型函数,一个把文本插入模型函数. talkList 是存储数据的列表,类型为 QList<QSharedPointer<TalkDataBasic>>TalkDataBasic 是我们上边定义的结构体,用来存储单个“聊天数据”的基本信息。QSharedPointer 智能指针用于自动管理对象的生命周期,避免内存泄漏。

class TalkListModel : public QAbstractListModel
{
    Q_OBJECT
public:
    TalkListModel();

    //重写三个函数
    int rowCount(const QModelIndex &parent = QModelIndex()) const override;
    QHash<int,QByteArray> roleNames() const override;
    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;

    //清空数据
    Q_INVOKABLE void clearModel();

    //发送文本
    Q_INVOKABLE void appendText(const QString &user,
                                const QString &sender,
                                const QString &text);

private:

    QList<QSharedPointer<TalkDataBasic>> talkList;
};

roleNames()

roleNames() 返回一个哈希表,定义了模型的数据角色(也就是字段名称)。这些角色是视图中访问和显示数据时使用的标识符。
例如,Qt::UserRole + 0 对应 "id" 字段,Qt::UserRole + 1 对应 "user" 字段,Qt::UserRole + 100 对应 "text" 字段等。

QHash<int, QByteArray> TalkListModel::roleNames() const
{
    return QHash<int,QByteArray>{
        //基本信息
        {Qt::UserRole+0,"id"},
        {Qt::UserRole+1,"user"},
        {Qt::UserRole+2,"sender"},
        {Qt::UserRole+3,"datetime"},
        {Qt::UserRole+4,"type"},
        {Qt::UserRole+5,"status"},
        //文本
        {Qt::UserRole+100,"text"}
    };
}

data

data() 方法根据传入的 role 参数返回对应的数据信息。根据 index.row() 获取对应的数据项(TalkDataBasic 对象)。
switch(role) 根据不同的角色返回不同的字段:
Qt::UserRole + 0 返回 id。
Qt::UserRole + 1 返回 user。
Qt::UserRole + 3 返回 datetime,将时间戳转换为可读的时间格式(yyyy-MM-dd hh:mm)。
Qt::UserRole + 100 通过类型转换,将 TalkDataText 对象中的文本内容返回。

QVariant TalkListModel::data(const QModelIndex &index, int role) const
{
    if(!index.isValid()){
        return QVariant();
    }
    const int row = index.row();
    auto item = talkList.at(row);

    switch(role)
    {
        default:
            break;
        case Qt::UserRole+0:
            return item->id;
        case Qt::UserRole+1:
            return item->user;
        case Qt::UserRole+2:
            return item->sender;
        case Qt::UserRole+3:
            return QDateTime::fromMSecsSinceEpoch(item->datetime).toString("yyyy-MM-dd hh:mm");
        case Qt::UserRole+4:
            return item->type;
        case Qt::UserRole+5:
            return item->status;
        case Qt::UserRole+100:
        {
            //文本
            TalkDataText *talk_text = static_cast<TalkDataText*>(item.get());
            return talk_text->text;
        }
    }
}

clearModel()

重置模型,把所有的东西都清空

void TalkListModel::clearModel()
{
    beginResetModel();
    talkList.clear();
    endResetModel();
}

appendText()

用于向 talkList 中添加一条新的聊天记录(具体是文本消息类型的记录).由于是发送文本,所以创建一个 TalkDataText对象包含text同时继承其他的东西,最后插入模型的时候需要把它强制转换为 QSharedPointer<TalkDataBasic>,方便使用智能指针去管理.

void TalkListModel::appendText(const QString &user, const QString &sender, const QString &text)
{
    TalkDataText *talk_data = new TalkDataText;
    talk_data->id = 0;
    talk_data->user = user;
    talk_data->sender = sender;
    talk_data->datetime = QDateTime::currentSecsSinceEpoch();
    talk_data->type = TalkData::Text;
    talk_data->status = TalkData::ParseSuccess;
    talk_data->text = text;

    //插入模型
    beginInsertRows(QModelIndex(),talkList.count(),talkList.count());
    talkList.push_back(QSharedPointer<TalkDataBasic>(talk_data));
    endInsertRows();
}

然后就可以在main.cpp里注册

qmlRegisterType<TalkData>("TalkModel",1,0,"TalkData");
qmlRegisterType<TalkListModel>("TalkModel",1,0,"TalkListModel");

修改为单例模式

消息展示

TalkItemBase.qml

TalkItemBase.qml 提供了一个通用的消息条目布局,包含头像和内容区域。

布局结构:

包含三个部分:左侧头像(left_item)、中间的内容区域(content_item)和右侧头像(right_item)。
左侧和右侧头像的显示与消息的发送者有关,用户消息显示在右侧,其他用户的消息显示在左侧,一个隐藏一个显示.
content_item 是显示消息内容的区域,这个区域的宽度是动态计算的,依赖于左右两侧的头像宽度。

TalkLabel.qml

TalkLabel.qml 定义了一个自定义的文本标签,用于显示聊天中的文本内容和其他文本内容。这个组件基于 TextEdit,但设置为只读状态,可以让文本进行选择和复制:

特点:
使用 TextEdit 而非 Text,这样可以启用文本选择(selectByMouse: true)功能,用户可以通过鼠标选择文本。
设置了 wrapMode: TextEdit.WrapAnywhere,确保文本在超过宽度时自动换行。
为了计算文本宽度,使用了一个隐藏的 Text 元素(private_text),它根据实际文本内容计算宽度,避免了绑定循环问题。
样式:使用了 Microsoft YaHei 字体,颜色为灰色(#666666),并设置了适当的内边距和文本对齐方式。

TalkItemText.qml

TalkItemText.qml 继承自 TalkItemBase.qml,是用于显示文本消息的具体实现。它通过 TalkItemBase 创建基础布局,并对文本消息进行扩展:

布局结构:
TalkItemBase 的基础上,添加了显示消息内容的区域,使用了 Row 来横向布局文本框(text_item)和消息时间(datetime)。
wrap_item 是包含消息文本的矩形框,背景颜色根据 isUser 动态变化。消息的箭头(指向发言人)通过小的矩形框实现。
消息文本 (TalkLabel) 显示在 wrap_item 内部,文本内容来自 model.text_text
Row 内的第二部分用于显示消息的时间(model.datetime)。

TalkListView.qml

alkListView.qml 定义了整个聊天窗口的 ListView,用于显示所有的聊天信息,并支持不同类型的消息(如文本和音频消息)。这个 ListView 会根据消息类型加载不同的组件(如 TalkItemText )。

主要功能:
talkModel:绑定数据模型,提供聊天消息的数据。
使用 Loader 动态加载不同类型的消息组件(文本消息),通过 model.type 判断消息类型。
提供了自定义的头部和尾部(header 和 footer)来控制列表的上下边距。
自定义垂直滚动条样式,调整滚动条的宽度、颜色和交互效果。

问题处理

运行发现界面上点击发送没有显示,看到模型是已经拿到数据了

file

发现是是splitView,去掉后可以看到能够显示信息,但是乱了,这样就有修改方向了

file

发现是文本这块就没有指定x位置,所以一直在最左侧.目前连发消息的高度也不对,一直是头像的高度,所以因该是文本信息的高度和TalkITemBase里的content_item的高度没有对应

在Base里添加上 default property alias contentItems: content_Item.children,意思就是从Base继承的qml,里边写的内容都会放到 content_Item里,太牛了

消息列表无法滚动到最新的消息,使用 posiionViewAtEnd()发现只能滚动到倒数第二个,并且大佬写的 highlightFollowsCurrentItem并没有生效,于是使用了定时器延时滚动

   Timer{
        id:timer
        interval:50;
        running: false;
        repeat:false;
        onTriggered:positionViewAtEnd();
    }
    onCountChanged:{
        if(count > 0)
        {
            timer.restart();
        }
    }

其他功能

复制粘贴,CopyPaste.qml

界面搭设,使用Menu和MenuItem组件,同时在点击时发送信号,InputItem接受信号并复制粘贴

Item {
    anchors.fill: parent
    property bool bHovered1: false
    property bool bHovered2: false
    signal sigPaste();
    signal sigCopy();
    Menu{
        id:contextMenu
        width:80
        background: Rectangle {
                    color: colorConfig.white  // 背景色
                    radius: 2  // 圆角
        }
        font: {
            pixelSize:8
            family: colorConfig.strTextFont
        }
        MenuItem{
            height:34
            text: qsTr("复制")
            background:Rectangle {
                id:copyBackground
                color:bHovered1 ? "#e2e2e2" :colorConfig.white // 背景色
                radius: 2  // 圆角
            }
            onHoveredChanged: {
                bHovered1 = !bHovered1;
            }
            //发送复制信号
            MouseArea{
                anchors.fill:parent
                onClicked: {
                    sigCopy();
                }
            }
        }
        MenuItem{
            id:pasteBtn
            height:34
            text: qsTr("粘贴")
            background:Rectangle {
                color:bHovered2 ? "#e2e2e2" :colorConfig.white // 背景色
                radius: 2  // 圆角
            }
            onHoveredChanged: {
                bHovered2 = !bHovered2;
            }
            //发送粘贴信号
            MouseArea{
                anchors.fill:parent
                onClicked: {
                    sigPaste();
                }
            }
        }
    }
    MouseArea{
        anchors.fill:parent
        acceptedButtons: Qt.RightButton
        onClicked : {
            contextMenu.x = mouse.x
            contextMenu.y = mouse.y
            contextMenu.popup();
        }
    }
}

IntputItem

CopyPaste{
        id:pasteBtn
    }

 //连接复制粘贴的信号
        Connections{
            target: pasteBtn
            onSigPaste:{
                textArea.paste();
            }
            onSigCopy:{
                textArea.copy();
                if (textArea.selection){
                console.log("copy")
                }
            }
        }

测试发现,粘贴好使但是复制不好使.排查一下原因.发现是当我点击右键的时候选中的文本就取消选中了,因此复制了空的东西导致无法复制.点击右键选中的蓝色文本调消失了

file

一番寻找后发现有这样的一个属性 persistentSelection: true //点击右键不会取消文本选中 5.15才有?,打开就是会保留原来选中的文本

发送时有空行也会一起发送

可以看到,底下的空行应该是不出现的

file

在点击发送时使用字符串匹配清空后边多余的空白

 onClicked: {
                // console.log("send");
                // 清理文本末尾的空白字符(包括空格、换行符)
                var trimmedText = textArea.text.replace(/\s+$/, ""); // 正则表达式匹配末尾空白字符
                talkListModel.appendText("A","A",trimmedText)
                // console.log(textArea.text)
            }

增加换肤功能

首先就是增加一个按钮,换肤按钮,点击换肤,在TitleItem里使用信号告诉main需要换肤,在main里把 lightModel的值进行变换.

按照思路修改完,发现发送按钮存在问题,排查发现是 =号赋值打断了原来的绑定

file

修改完成深色模式搞定了

file

增加界面缩放功能

最小尺寸550620 最大尺寸1600\1020

拖拉缩放主窗口

按下鼠标时(onPressed

  • 当鼠标按下时,onPressed 事件被触发。此时我们记录当前窗口的宽度、高度和鼠标的初始位置。
  • initialWidth = mainWindow.width:记录当前窗口的宽度。
  • initialHeight = mainWindow.height:记录当前窗口的高度。
  • initialMouseX = mouse.xinitialMouseY = mouse.y:记录当前鼠标相对于 resizeArea 的位置(即窗口右下角的区域)。
onPressed: {
    initialWidth = mainWindow.width
    initialHeight = mainWindow.height
    initialMouseX = mouse.x
    initialMouseY = mouse.y
}

鼠标移动时(onPositionChanged

  • onPositionChanged 中,我们实时追踪鼠标的位置变化。
  • 鼠标当前位置与按下时的初始位置之间的偏移量(deltaXdeltaY)用于计算窗口的宽度和高度的变化量。
  • deltaX = mouse.x - initialMouseX:计算鼠标水平方向上的偏移量。
  • deltaY = mouse.y - initialMouseY:计算鼠标垂直方向上的偏移量。

然后,将这些偏移量加到窗口的初始尺寸上,得到新的窗口尺寸:

  • mainWindow.width = Math.max(initialWidth + deltaX, 550):新的宽度为初始宽度加上水平偏移量(deltaX),但不能小于 400 像素。
  • mainWindow.height = Math.max(initialHeight + deltaY, 620):新的高度为初始高度加上垂直偏移量(deltaY),但不能小于 300 像素。

通过 Math.max,我们确保窗口的宽度和高度不会小于设定的最小值。

onPositionChanged: {
                //计算鼠标相对位置,调整窗口的宽度和高度
                if(mouse.x !== initialMouseX || mouse.y !== initialMouseY)
                {
                    var deltaX = mouse.x - initialMouseX;
                    var deltaY = mouse.y - initialMouseY;
                    mainWindow.width = Math.max(initialWidth + deltaX,550) //设置最小宽度值
                    mainWindow.height = Math.max(initialHeight + deltaY,620) //设置最小宽度值

                }
            }

使用Column+四个组件,将4个组件的上下关系都绑定,top,bottom,然后将 TitleItem,ToolItem的高度固定,他两高度是不变的.所有组件的宽度就是父类大窗口的宽度,发现会报错,而且绑定关系也对应不上.因此将column改为Rectangle.

file

标题顶部绑定父类窗口,高度固定80

消息框顶部绑定标题底部,底部绑定工具栏顶部,高度变化

工具栏底部绑定输入框顶部,高度固定40

输入框底部绑定父类窗口底部,高度首次固定150(后边可以开启调整高度)

Rectangle{
        anchors.fill: parent
        //标题栏
        TitleItem{
            id:titleItem
            height: 80
            anchors.top: parent.top
            width: parent.width
            window: mainWindow
        }
        //消息框
        ChatContentItem{
            id:chatContentItem
            width: parent.width
            anchors.top: titleItem.bottom
            anchors.bottom: toolItem.top
            TalkListView{
                id:talk_view
                anchors.fill:parent
                model:talkListModel
            }
        }
        //工具栏
        ToolItem{
            id:toolItem
            width: parent.width
            anchors.bottom:inputItem.top
        }
        //输入框
        InputItem{
            id:inputItem
            height:150;
            width: parent.width
            anchors.bottom: parent.bottom
        }
    }

消息框在大尺寸的时候需要约束尺寸不用这么长了,textItem.implicitWidth是为了当你在较大的尺寸下发了很少的文字,设置聊天消息框的长度为文字的宽度值

TalkLabel{
                id:textItem;
                text:model.text;
                persistentSelection: true  //点击右键不会取消文本选中 5.15才有?
                width:talkItemText.contentWidth < 400 ? Math.min(talkItemText.contentWidth,textItem.implicitWidth):
                                                        Math.min(textItem.implicitWidth,Math.min(talkItemText.contentWidth/4*3,800)); //聊天记录的宽度
                color:talkItemText.isUser ? "#111111" :colorConfig.strTextColor ;
            }

拉伸界面闪烁严重

看到一片文章https://github.com/mengps/QmlControls/tree/master/FramelessWindow,可能可以采用.但是我因该用不到拉伸,一次性放大缩小,所以不改了

任务栏没有窗口图标

因为我们把窗口的标题栏去了,所以窗口图标也没了,添加上 Qt.window就能够出现 ,flags:Qt.Window | Qt.FramelessWindowHint //去掉原始标题栏

时间优化

现在时间是显示到发送者下方且每次都显示,想优化到中间,间隔差了5分钟在显示.在qml中尝试失败了,在loader中绑定信号老是绑定不上,决定使用c++来解决.在listModel里新增函数 caculateTime()来计算时差,大于5分钟就把lastShowTime覆盖.当时差小于5分钟我们把时间戳返回0,对应到时间就是 1970-01-01 08:00.

qint64 TalkListModel::caculateTime()
{
    qint64 currentTime = QDateTime::currentSecsSinceEpoch();
    //记录首次时间
    if(lastShowTime == 0)
    {
        lastShowTime = currentTime;
        return lastShowTime;
    }
    else //计算时差重置最后时间
    {
        QDateTime lastDateTime = QDateTime::fromSecsSinceEpoch(lastShowTime);
        QDateTime currentDateTime = QDateTime::fromSecsSinceEpoch(currentTime);

        //计算时差
        qint64 timeDiff = lastDateTime.secsTo(currentDateTime);

        timeDiff = timeDiff / 30; //转换为分钟

        qDebug() << "timeDiff" << timeDiff;
        if(timeDiff >= 1 )
        {
            lastShowTime = QDateTime::currentSecsSinceEpoch();
            return lastShowTime;
        }
        return 0;
    }
}

然后就去qml里修改显示逻辑,同时把时间在子项里挪到前头,居中

property bool bShowTime : model.datetime === "1970-01-01 08:00" ? false : true

//显示时间
    TalkLabel{
        id:timeItem
        text:model.datetime
        anchors.horizontalCenter: parent.horizontalCenter
        padding:0
        font{
            pixelSize: 14
        }
        color: colorConfig.strTimeTextColor;
        visible: bShowTime  //控制时间显示
    }

但是发现个问题,这样子会打乱布局,头像跟气泡框不平行了,自己尝试过把时间单独一块,消息就乱了,不是很好改,只能曲线救国,显示时间时让头像框下移一点

Image{
                y: bShowTime ? 25 : 0
                visible: !talkItemBase.isUser;
                width:talkConfig.nProfilePictureHeight;

file

文件发送

想使用文件弹出框选择文件发送,之前做过一个类似的就使用它了.新建 FileModel继承自 QObject,使用单例模式给qml一个接口 getSelectedFileUrl拿到选择文件的本地地址.

方案1 C++的Filedialog

头文件 QFileDialog属于QWidgets,所以需要在.pro文件中+上widgets,把函数 getSelectedFileUrl暴露给qml,我们在main.cpp注册后就能够直接调用.

头文件

#ifndef FILEMODEL_H
#define FILEMODEL_H

#include <QObject>
#include <QFileDialog>
#include <QUrl>

class FileModel : public QObject
{
    Q_OBJECT
public:
    static FileModel *getInstance();

    //获取选择的文件路径
    Q_INVOKABLE QUrl getSelectedFileUrl();

private:
    //私有化构造
    FileModel();
    //禁止复制构造和赋值操作
    FileModel(const FileModel&) = delete;
    FileModel &operator = (const FileModel&) = delete;
    static FileModel *instance;     //静态实例

signals:
};

#endif // FILEMODEL_H

cpp文件

#include "filemodel.h"

//一开始需要置空,类外赋值
FileModel* FileModel::instance = nullptr;
FileModel::FileModel()
{

}

FileModel *FileModel::getInstance()
{
    if(instance == nullptr)
    {
        instance = new FileModel();
    }
    return instance;
}

QUrl FileModel::getSelectedFileUrl()
{
    QUrl filePath = QFileDialog::getOpenFileUrl(
        nullptr,
        QString("选择文件"),                            //标题
        QUrl(),                                     //默认路径
        QString("所有文件 (*);;文本文件 (*.txt)"),      //筛选格式
        nullptr,
        QFileDialog::DontUseNativeDialog            //不使用官方样式,自定义
        );    //过滤文件
    return filePath;
}

在main.cpp里注册

//文件单例
    FileModel* fileModel = FileModel::getInstance();
    engine.rootContext()->setContextProperty("fileModel",fileModel);

选择文件弹窗样式自定义

看别人都是写一个qss,然后 setStyleSheet,把这个打开就行 QFileDialog::DontUseNativeDialog.但自己这里不行不知道为啥

QUrl FileModel::getSelectedFileUrl()
{
    // 获取父窗口,若没有则使用 nullptr
    QWidget *parentWindow = nullptr;
    QFileDialog fileDialog(parentWindow);

    const QString FileDialogQSS =
        "QWidget { background-color: yellow }";
    // 设置 QSS
    fileDialog.setStyleSheet(FileDialogQSS);
    fileDialog.setOption(QFileDialog::DontUseNativeDialog, true);

    QUrl filePath = fileDialog.getOpenFileUrl(
        nullptr,
        QString("选择文件"),                            //标题
        QUrl(),                                     //默认路径
        QString("所有文件 (*);;文本文件 (*.txt)"),      //筛选格式
        nullptr,
        QFileDialog::DontUseNativeDialog            //不使用官方样式,自定义
        );    //过滤文件
    return filePath;
}

方案2 qml的Filedialog

我记得在之前写程序的时候,qml的filedialog卡不住程序,达不到我想要的效果

FileDialog {
    id: fileDialog
    title: "请选择文件"

    // 选择文件后触发
    onAccepted: {
        // 获取选中的文件 URL
        console.log("选择的文件:", fileDialog.fileUrls[0].toString())
    }
}

信号连接打开文件弹窗

查询了一些资料因为我可能还要修改文件弹窗的样式,所以就用c++的方式来实现了,qml的没有找到怎么修改样式

file

由于我们的小按钮都是通过Repeater重复创建的,所以无法单独控制,让他们点击时都发出一个信号,信号传出图标名来区分我们该进行什么操作

/左侧标签,文件,记录按钮
        Repeater{
            model:colorConfig.leftModel.split(",");
            delegate:IconButton{
                imageSource: modelData

                MouseArea{
                    anchors.fill: parent
                    onClicked: {
                        console.log(modelData);
                        sigBtnClicked(modelData)
                    }
                }
            }
        }

file

然后我们在main.qml里接受信号作出操作

//选择文件传送
    Connections{
        target:toolItem
        onSigBtnClicked:{
            //按下的是文件按钮
            if(btnName === "qrc:/icon/file_dark.png" || btnName === "qrc:/icon/file_light.png")
            {
                filePath = fileModel.getSelectedFileUrl();
                console.log(filePath);
            }
        }
    }

点击运行后出现错误 QWidget: Cannot create a QWidget without QApplication,把main.cpp里的 QGuiApplication改为 QApplication解决了这个报错,成功显示弹窗,并且能够拿到选择文件的地址

file

图片发送

接下来就是把文件路径存入模型,发送(我这不涉及实际的网络发送,所以相当于是把图片放到聊天界面上而以),在模型中按照文本的方式创建函数 appendImage

//图片消息
void TalkListModel::appendImage(const QString &user, const QString &sender, const QUrl &ImageUrl)
{
    TalkDataImage *talk_data = new TalkDataImage;
    talk_data->id = 0;
    talk_data->sender = sender;
    talk_data->datetime = QDateTime::currentSecsSinceEpoch();
    talk_data->type = TalkData::Image;
    talk_data->status = TalkData::ParseSuccess;
    talk_data->imageUrl = ImageUrl;

    //插入模型
    beginInsertRows(QModelIndex(),talkList.count(),talkList.count());
    talkList.push_back(QSharedPointer<TalkDataBasic>(talk_data));
    endInsertRows();
}

创建一个 TalkItemImage,仿照Text同时在 ListView根据数据的类型创建 TalkItemImage

//图片delegate
TalkItemBase{
    id:talkItemText
    Row{
        width: talkItemText.contentWidth
        layoutDirection: talkItemText.isUser ? Qt.RightToLeft : Qt.LeftToRight
        Image{
            source: model.imageUrl;
        }
    }
}

ListView

// 委托:加载不同类型的组件,基于 `model.type` 的值选择合适的组件
    delegate: Loader{
        sourceComponent: {
            switch(model.type){
            case TalkData.Text:
            {
                return textComp;
            }
            case TalkData.Image:
            {
                return imageComp;
            }

            return nullComp;
            }
        }

        Component{
            id:textComp
            TalkItemText{}
        }
        Component{
            id:imageComp
            TalkItemImage{}
        }
    }

在到信号接受的地方,把图片的url传入,运行发现报错,Qurl不是Image的source类型

onSigBtnClicked:{
            //按下的是文件按钮
            if(btnName === "qrc:/icon/file_dark.png" || btnName === "qrc:/icon/file_light.png")
            {
                filePath = fileModel.getSelectedFileUrl();
                console.log(filePath);
                talkListModel.appendImage("A","A",filePath)
            }
        }

file

排查发现是ItemImage没有访问到模型,在里边打印模型,东西是空的.排查了很久发现是模型没有给Image类型role,也就是在外边的model根本就访问不到这个图片地址,因为我都没有给他接口

//图片
{Qt::UserRole+200,"imagePath"}

 case Qt::UserRole+200:
        {
            //图片
            TalkDataImage *talk_image = static_cast<TalkDataImage*>(item.get());
            return talk_image->imageUrl;
        }

缩放图像,如果高宽大于250就缩放,和250计算比例相除,把最长的边缩放到250

Row{
        width: talkItemImage.contentWidth
        layoutDirection: talkItemImage.isUser ? Qt.RightToLeft : Qt.LeftToRight
        Image{
            id:chatImage
            source: model.imagePath;
            //判断是否需要缩放图像
            Component.onCompleted: {
                if(chatImage.sourceSize.width > talkConfig.nImageMaxWidth || chatImage.sourceSize.height > talkConfig.nImageMaxHeight )
                {
                    var ratio = Math.max(chatImage.sourceSize.width / talkConfig.nImageMaxWidth , chatImage.sourceSize.height /talkConfig.nImageMaxHeight);
                    chatImage.sourceSize = Qt.size(chatImage.sourceSize.width / ratio,chatImage.sourceSize.height / ratio )
                }
            }
        }
    }

file

其他文件发送

图片需要展示,其他文件不需要展示,只是显示大小,类型,一个小的消息框

file

因此我们需要在fileModel中拿到选择文件的名称大小,后缀.直接把它归到消息base类了,感觉跟方便一点,其他就跟Image一样创建就行

ListModel

//消息基类
struct TalkDataBasic
{
    qint64 id;          //消息id
    QString user;       //消息所属用户
    QString sender;     //发送者
    qint64 datetime;    //发送时间 ms
    //消息类型:文本,语音,图片,视频
    TalkData::DataType type = TalkData::Text;
    //处理状态
    TalkData::DataStatus status = TalkData::DataError;
    QString fileName;   //名字
    QString fileSize;   //大小
    QString filePath;   //路径
    QString fileSuffix; //后缀
};

void TalkListModel::appendOtherFile(const QString &user, const QString &sender, const QString &filePath, const QString &fileName, const QString &fileSize, const QString &fileSuffix)
{
    TalkDataOtherFile *talk_data = new TalkDataOtherFile;
    talk_data->id = 0;
    talk_data->user = user;
    talk_data->sender = sender;
    talk_data->datetime = caculateTime();
    talk_data->type = TalkData::Other;
    talk_data->status = TalkData::ParseSuccess;
    talk_data->filePath = filePath;
    talk_data->fileSize = fileSize;
    talk_data->fileName = fileName;
    talk_data->fileSuffix = fileSuffix;

    //插入模型
    beginInsertRows(QModelIndex(),talkList.count(),talkList.count());
    talkList.push_back(QSharedPointer<TalkDataBasic>(talk_data));
    endInsertRows();
}

fileModel里处理文件,拿到信息,把信息传递给qml,qml在发送给listmodel,好像绕了,先就这么实现把.把数据转换为map格式,能够让qml一次拿去很多数据

fileSizeStrQVariant FileModel::extractFileInfo(const QString &filePath)
{
    QUrl url(filePath);
    QString localPath = url.toLocalFile(); //将qml路径转换为本地路径

    QFileInfo fileInfo(localPath);
    // 获取文件的大小、文件名和文件后缀
    qint64 fileSize = fileInfo.size();
    QString fileName = fileInfo.fileName();    // 文件名 + 后缀
    QString fileSuffix = fileInfo.suffix();    // 文件后缀(如:jpeg)
    QString fileSizeStr = formatFileSize(fileSize);

    // 打印输出
    qDebug() << "File Path: " << localPath;
    qDebug() << "File Name: " << fileName;
    qDebug() << "File Size: " << fileSize ;
    qDebug() << "File Suffix: " << fileSuffix;

    QVariantMap map; //把数据变成qml能够接受的格式
    map["fileName"] = fileName;
    map["fileSize"] = fileSizeStr;
    map["fileSuffix"] = fileSuffix;
    return map;
}

QString FileModel::formatFileSize(qint64 size)
{
    if (size < 1024)
        return QString::number(size) + " B";
    else if (size < 1024 * 1024)
        return QString::number(size / 1024.0, 'f', 2) + " KB";
    else if (size < 1024 * 1024 * 1024)
        return QString::number(size / (1024.0 * 1024), 'f', 2) + " MB";
    else
        return QString::number(size / (1024.0 * 1024 * 1024), 'f', 2) + " GB";
}

按照Image一样创建qml文件,listmodel里创建组件,在main里只能够用switchcase来判断文件类型进入不同的函数了

property var fileInfo;

//选择文件传送
    Connections{
        target:toolItem
        onSigBtnClicked:{
            //按下的是文件按钮
            if(btnName === "qrc:/icon/file_dark.png" || btnName === "qrc:/icon/file_light.png")
            {
                filePath = fileModel.getSelectedFileUrl();
                fileInfo = fileModel.extractFileInfo(filePath)
                count++
                if(filePath.toString() !== "")
                {
                    switch(fileInfo.fileSuffix) {
                        case "jpg" :
                        {
                            console.log(1111)
                            if(count %2 === 0)
                            {
                                talkListModel.appendImage("A","A",filePath.toString())
                            }
                            else{
                                talkListModel.appendImage("B","A",filePath.toString())
                            }
                            return;
                        }
                        case "pdf":
                        {
                            console.log("pdf")
                            if(count %2 === 0)
                            {
                                talkListModel.appendOtherFile("A","A",filePath.toString(),fileInfo.fileName,fileInfo.fileSize,fileInfo.fileSuffix);
                            }
                            else{
                                talkListModel.appendOtherFile("A","A",filePath.toString(),fileInfo.fileName,fileInfo.fileSize,fileInfo.fileSuffix);
                            }
                        }
                    }

                }
            }
        }
    }

可以看到打印了pdf也就是进入到了pdf分支,而且消息也有空的展示,说明消息也发出去了,剩下就是搭建消息的界面了

文件发送的界面位置都不会变,固定的搭建就行了,文件名太长不予展示,后边用…加上,增加个判断,名字超长

file

如果fileName大于20个字符,则截取前17个字符在拼接上…text:model.fileName.length > 18 ? model.fileName.substring(0,16) + "..." : model.fileName;,但是发现中文是占2个字符,而英文只占1个字符,中文两排,英文就只有一排了,所以得计算所有的字符,当字符长度大于两排展示的时候就截断

//其他文件delegate
TalkItemBase{
    id:talkItemOtherFile

    //截取文件名
    function extractTextFileName(){
        var TextSize = 0; //字符长度
        var TextCount = 0; //计数
        for(TextCount;TextCount < model.fileName.length;TextCount++)
        {
            TextSize += model.fileName.charCodeAt(TextCount) > 255 ? 2 : 1  //中文为2个字符,英文为1个
            if(TextSize > 38)
            {
                return model.fileName.substring(0,TextCount) + "..."
            }
        }
        return fileName;
    }

    Rectangle{
        width:40
        height:20
        color: colorConfig.strTimeBackgroundColor
        radius:4
        //显示时间
        x:contentWidth/2-width/2
        visible:bShowTime //控制时间显示
        TalkLabel{
            id:timeItem
            text:model.datetime
            padding:0
            anchors.centerIn: parent
            font{
                pixelSize: 14
            }
            color: colorConfig.strTimeTextColor;
            visible: bShowTime  //控制时间显示
        }
    }

    Row{
        width: talkItemOtherFile.contentWidth
        layoutDirection: talkItemOtherFile.isUser ? Qt.RightToLeft : Qt.LeftToRight
        Rectangle{
            id:wrapItem
            radius:4
            width:250
            height:80
            color: "#ffffff"  //气泡颜色

            //指向发言人的小三角
            Rectangle{
                width:10;
                height:10;
                y:talkConfig.nMessageHeight/2-10;
                anchors.horizontalCenter: talkItemOtherFile.isUser ? parent.right :parent.left
                rotation: 45
                color:"#ffffff"  //气泡颜色
            }
            TalkLabel{
                id:textItem;
                width: 200
                height: 40
                // text:model.fileName.length > 18 ? model.fileName.substring(0,16) + "..." : model.fileName;
                text: extractTextFileName();
                persistentSelection: true  //点击右键不会取消文本选中 5.15才有?
                color:"#111111";
                font.pixelSize: 16
                wrapMode: TextEdit.WrapAnywhere
            }
            Image{
                id:chatImage
                anchors.verticalCenter: parent.verticalCenter
                x:190
                source:
                    switch(model.fileSuffix)
                        {
                            case "pdf":
                            {
                                return "qrc:/icon/PDF.png";
                            }
                            case "docx":
                            {
                                return "qrc:/icon/WORD.png";
                            }
                            case "csv":
                            {
                                return "qrc:/icon/EXCEL.png";
                            }
                            case "xlsx":
                            {
                                return "qrc:/icon/EXCEL.png";
                            }
                            case "txt":
                            {
                                return "qrc:/icon/TXT.png";
                            }
                            case "ppt":
                            {
                                return "qrc:/icon/PPT.png";
                            }
                            case "mp4":
                            {
                                return "qrc:/icon/VIDEO.png";
                            }
                            default:
                            {
                                return "qrc:/icon/UNKNOWN.png"
                            }
                        }
                smooth: true
                //判断是否需要缩放图
            }
            //文件大小
            Text{
                y:40
                padding:14
                id:fileSizeText
                text:model.fileSize
                font.pixelSize: 12
                font.family: colorConfig.strTextFont
                color:"#999999"
            }
        }
    }

}

file

表情发送

目前我是要把.png格式的图片展示到消息列表,查询到有这样的形式,设置 TextEdittextFormat:TextEdit.RichText,然后使用 <img src='qrc:/images/emoji_love.png' width='20' height='20' />,就能够展示表情

file

表情盘制作

首先使用c++获取表情包文件夹下所有的图片名字,暴露给qml,能够直接调用

QStringList FileModel::getEmojiName()
{
    QDir dir("../../icon/emoji/");//表情所在地址
    if(!dir.exists())
    {
        qDebug()<< "表情包路径不存在";
    }
    QStringList files = dir.entryList(QDir::Files | QDir::NoDotAndDotDot);
    return files;
}

对于表情轮盘的展示使用GridView排列,同时对每个按钮添加一个信号,然后在main里使用open打开弹窗即可


import QtQuick 2.13
import QtQuick.Controls 2.13
import "../../Common"

Popup
{
    id:emojiPopup
    width:285;
    height:180;
    //直接Rectangle有黑边而且有背景,使用contentItem就没有了
    contentItem: Rectangle{
        anchors.fill: parent
        color: "#ffffff";
        radius: 8;

        signal sigEmojiBtnClicked(string btnName);

        //左侧标签,文件,记录按钮
        GridView{
            anchors.fill: parent
            anchors.margins: 5
            cellWidth: 25  // 单元格宽度
            cellHeight: 25 // 单元格高度
            layoutDirection: Qt.LeftToRight
            flow: GridView.FlowLeftToRight

            // 禁止滚动行为
            interactive: false // 禁用用户交互滚动

            model:fileModel.getEmojiName();
            delegate:IconButton{
                width:20
                height:20
                imageSource:"qrc:/icon/emoji/"+modelData
                imgHeight: 20
                imgWidth: 20

                MouseArea{
                    anchors.fill: parent
                    onClicked: {
                        sigEmojiBtnClicked(modelData)
                    }
                }
            }
        }

        //小三角
        Rectangle{
            width:40
            height: 40
            rotation: 45
            z:-1
            anchors.bottom: parent.bottom
            anchors.horizontalCenter: parent.horizontalCenter
            color: "#ffffff";
        }
    }
    //设置背景
    background: Rectangle{
        color: "transparent"
        border.color: "transparent"
    }
}

file

信号发送表情

对于每个表情,我们都添加了点击信号,发送了表情的文件名相当于拿到了表情图片的地址,现在只需要接收信号,把表情地址按照上边的方式写入输入框的光标位置就行,在main里接受信号.把textArea暴露后就能直接访问 property alias textArea: textArea , inputItem.textArea.insert能直接从main访问到这个textArea,获取光标位置调用 insert函数

//表情发送
    Connections{
        target:emojiItem
        onSigEmojiBtnClicked:{
            var emojiPath = "qrc:/icon/emoji/"+btnName;
            var insertStr = "<img src='" + emojiPath + "' width='20' height='20' style='vertical-align:baseline;'/>"

            //插入表情
            inputItem.textArea.insert(inputItem.textArea.cursorPosition,insertStr)
        }
    }

发现有时候会有空格,但有是有又没有,应该是换行策略的问题,将换行策略变成 TextEdit.WrapAnywhere就行了

file

查询了一些资料发现Popup弹窗不能够在主窗口之外,只能用window实现,但是使用window的话会有很多问题,所以还是采用popup,贴边显示了然后让小三角显示到表情图标上头,同时使用 ToolItem来限制表情轮盘的位置

//表情弹窗
    EmojiItem{
        id:emojiItem;
        x:0
        y:toolItem.y-emojiItem.height-10
    }

file

代码重构

限制消息展示的数量

设置消息上限,到达上限时有新消息进入就把最开始的消息删除,但是发现这样子运行后消息不会滚动到最下编

void TalkListModel::controlDisplayMessagesCount()
{
    if(talkList.size() > nDisplayMessages)
    {
        beginRemoveRows(QModelIndex(),0,0);
        talkList.removeFirst();
        endRemoveRows();
    }
}

发现原因是因为我移动的定时器是根据model中的count数量来的,当数据量达到上限时,count就不会变了,所以就不会滚动到最新的消息,打印了发现确实,当数据量达到10个时,再发送消息,onCountChange就不会被触发了,应该是我把 controlDisplayMessagesCount这个函数放到添加的里边了,只有当外层函数结束才会统计整个函数里的count变化,我先减少在添加等于count没变.

将删除函数放到定时器里,让模型先插入完成视图后在删除,移动

 //消息滚动到最新
    Timer{
        id:timer
        interval:10;
        running: false;
        repeat:false;
        onTriggered:
        {
            model.controlDisplayMessagesCount();
            positionViewAtEnd();
        }
    }

    onCountChanged:{
        if(count > 0)
        {
            timer.restart();
        }
    }

把整个项目作为一个单独的qml组件嵌入到其他项目

如果觉得本文对您有所帮助,可以支持下博主,—分也是缘。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇