搭设一个仿微信聊天界面的ui,实现自己给自己发消息,然后要复用组件,不涉及网络数据库
代码github,建议参考提交记录和文档,核心思路参考文章文章链接
界面搭设
全局颜色配置文件
新建一个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文件,把图标放入
考虑到这个关闭按钮有很多按钮都可以用到同一个样式,也把他分装为一个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就算搭建完了
ToolItem.qml
仿照微信的这一行工具栏进行搭设,下载几个图标,
使用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
}
}
}
}
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作为展示.
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)来控制列表的上下边距。
自定义垂直滚动条样式,调整滚动条的宽度、颜色和交互效果。
问题处理
运行发现界面上点击发送没有显示,看到模型是已经拿到数据了
发现是是splitView,去掉后可以看到能够显示信息,但是乱了,这样就有修改方向了
发现是文本这块就没有指定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")
}
}
}
测试发现,粘贴好使但是复制不好使.排查一下原因.发现是当我点击右键的时候选中的文本就取消选中了,因此复制了空的东西导致无法复制.点击右键选中的蓝色文本调消失了
一番寻找后发现有这样的一个属性 persistentSelection: true //点击右键不会取消文本选中 5.15才有?
,打开就是会保留原来选中的文本
发送时有空行也会一起发送
可以看到,底下的空行应该是不出现的
在点击发送时使用字符串匹配清空后边多余的空白
onClicked: {
// console.log("send");
// 清理文本末尾的空白字符(包括空格、换行符)
var trimmedText = textArea.text.replace(/\s+$/, ""); // 正则表达式匹配末尾空白字符
talkListModel.appendText("A","A",trimmedText)
// console.log(textArea.text)
}
增加换肤功能
首先就是增加一个按钮,换肤按钮,点击换肤,在TitleItem里使用信号告诉main需要换肤,在main里把 lightModel
的值进行变换.
按照思路修改完,发现发送按钮存在问题,排查发现是 =
号赋值打断了原来的绑定
修改完成深色模式搞定了
增加界面缩放功能
最小尺寸550620 最大尺寸1600\1020
拖拉缩放主窗口
按下鼠标时(onPressed
) :
- 当鼠标按下时,
onPressed
事件被触发。此时我们记录当前窗口的宽度、高度和鼠标的初始位置。 initialWidth = mainWindow.width
:记录当前窗口的宽度。initialHeight = mainWindow.height
:记录当前窗口的高度。initialMouseX = mouse.x
和initialMouseY = mouse.y
:记录当前鼠标相对于resizeArea
的位置(即窗口右下角的区域)。
onPressed: {
initialWidth = mainWindow.width
initialHeight = mainWindow.height
initialMouseX = mouse.x
initialMouseY = mouse.y
}
鼠标移动时(onPositionChanged
) :
- 在
onPositionChanged
中,我们实时追踪鼠标的位置变化。 - 鼠标当前位置与按下时的初始位置之间的偏移量(
deltaX
和deltaY
)用于计算窗口的宽度和高度的变化量。 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.
标题顶部绑定父类窗口,高度固定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;
文件发送
想使用文件弹出框选择文件发送,之前做过一个类似的就使用它了.新建 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的没有找到怎么修改样式
由于我们的小按钮都是通过Repeater重复创建的,所以无法单独控制,让他们点击时都发出一个信号,信号传出图标名来区分我们该进行什么操作
/左侧标签,文件,记录按钮
Repeater{
model:colorConfig.leftModel.split(",");
delegate:IconButton{
imageSource: modelData
MouseArea{
anchors.fill: parent
onClicked: {
console.log(modelData);
sigBtnClicked(modelData)
}
}
}
}
然后我们在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
解决了这个报错,成功显示弹窗,并且能够拿到选择文件的地址
图片发送
接下来就是把文件路径存入模型,发送(我这不涉及实际的网络发送,所以相当于是把图片放到聊天界面上而以),在模型中按照文本的方式创建函数 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)
}
}
排查发现是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 )
}
}
}
}
其他文件发送
图片需要展示,其他文件不需要展示,只是显示大小,类型,一个小的消息框
因此我们需要在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分支,而且消息也有空的展示,说明消息也发出去了,剩下就是搭建消息的界面了
文件发送的界面位置都不会变,固定的搭建就行了,文件名太长不予展示,后边用…加上,增加个判断,名字超长
如果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"
}
}
}
}
表情发送
目前我是要把.png格式的图片展示到消息列表,查询到有这样的形式,设置 TextEdit
的 textFormat:TextEdit.RichText
,然后使用 <img src='qrc:/images/emoji_love.png' width='20' height='20' />
,就能够展示表情
表情盘制作
首先使用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"
}
}
信号发送表情
对于每个表情,我们都添加了点击信号,发送了表情的文件名相当于拿到了表情图片的地址,现在只需要接收信号,把表情地址按照上边的方式写入输入框的光标位置就行,在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
就行了
查询了一些资料发现Popup弹窗不能够在主窗口之外,只能用window实现,但是使用window的话会有很多问题,所以还是采用popup,贴边显示了然后让小三角显示到表情图标上头,同时使用 ToolItem
来限制表情轮盘的位置
//表情弹窗
EmojiItem{
id:emojiItem;
x:0
y:toolItem.y-emojiItem.height-10
}
代码重构
限制消息展示的数量
设置消息上限,到达上限时有新消息进入就把最开始的消息删除,但是发现这样子运行后消息不会滚动到最下编
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();
}
}