written by SJTU-XHW
Reference: C++ GUI Programming with Qt 4 (2nd Edition)
注意:本文章将讲解 Qt 5 入门知识,需要一定的 C++ 基础
本人知识浅薄,文章难免有问题或缺漏,欢迎批评指正!
观前提示:本系列的所有完整代码在整理好后,都会存放在仓库中,有需要可以自取 ~
Chapter 4 第一个完整的 Qt 入门项目
在完成以上的学习过程后,在座诸位都具有独立写出一个极简的、 较为完整 Qt 项目的能力;
以下,本人将用 纯代码方式 方式完成这个项目;
本章末,会总结到目前为止学到的所有 Qt 类的继承/思维图 。
观前提示:本项目的设计思路很长,完整源代码放在 仓库 里,有需要可以取出查看 ~ 本章的目的是为了学习 Qt 一些组件的用法而已 ~
项目目标:模仿 Microsoft Excel
设计一个表格应用程序 。
4.1 创建主窗口 UI 4.1.1 子类化 QMainWindow 创建一个主窗口最方便的方法是利用 Qt 库中已有的设计类:QMainWindow
。
QMainWindow
和 QDialog
都是 QWidget
的子类,所以之前的很多方法在创建主窗口时同样有效。
编写前先认识 QMainWindow
类的对应结构:
其中中央窗口部件可以放置 Widget,使用 QMainWindow::setCentralWidget(QWidget*)
在此区域添加 Widget;
首先根据目标,声明一个主窗口类(自己起个名字,这里用 MainWindow
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 #pragma once #include <QtWidgets/QMainWindow> #include <QAction> #include <QtWidgets/QLabel> #include "FindDialog.h" #include "HolySheet.h" #define MaxRecentFiles 5 class MainWindow : public QMainWindow { public : MainWindow (); protected : void closeEvent (QCloseEvent* event) override ; private slots: void newFile () ; void open () ; bool save () ; bool saveAs () ; void find () ; void goToCell () ; void sort () ; void about () ; void openRecentFile () ; void updateStatusBar () ; void holySheetModified () ; private : void createActions () ; void createMenus () ; void createContextMenu () ; void createToolBars () ; void createStatusBar () ; void readSettings () ; void writeSettings () ; bool okToContinue () ; bool loadFile (const QString& filename) ; bool saveFile (const QString& filename) ; void setCurrentFile (const QString& filename) ; void updateRecentFileAction () ; QString strippedName (const QString& fullFileName) ; HolySheet* holySheet; FindDialog* findDialog; QLabel* locationLabel; QLabel* formulaLabel; QStringList recentFiles; QString curFile; QAction* recentFileActions[MaxRecentFiles]; QAction* separatorAction; QMenu* fileMenu; QMenu* editMenu; QToolBar* fileToolBar; QToolBar* newAction; QAction* newAction; QAction* openAction; QAction*aboutAction; };
现在实现以上的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #include <QtGui> #include "MainWindow.h" MainWindow::MainWindow () { holySheet = new HolySheet; setCentralWidget (holySheet); createActions (); createMenus (); createContextMenu (); createToolBars (); createStatusBar (); readSettings (); findDialog = nullptr ; setWindowIcon (QIcon (":/images/icon.png" )); setCurrentFile ("" ); }
这里需要说明的是 QMainWindow::setWindowIcon(QIcon)
和 QIcon
类;
在应用程序需要读取一些资源的时候,通常开发者会把资源放在一个文件夹中,需要的时候通过和系统相关的路径 载入,这种方法可移植性较低,并且容易因为移动而丢失资源,最讨厌的是资源路径比较麻烦 ;
因此,比较推荐的是 Qt 的资源机制(resource mechanism) ,优点是比运行时载入更方便、适用于任意文件格式;推荐的使用方法是:
将资源分类放在各目录下,比如图片放在项目创建的 images
子目录下;
新建 Qt 资源系统文件 *.qrc
,名字自己起,格式是 XML
,举个例子(用相对路径):
1 2 3 4 5 6 <RCC > <qresource > <file > images/icon.png</file > <file > images/gotocell.png</file > </qresource > </RCC >
记得在项目 *.pro
文件中添加:RESOURCE = yourFileName.qrc
(如果您用 Qt Creator IDE 自动添加,就当我没说)
这样在 Qt 程序的绝大多数地方路径字符串 :/
代表 *.qrc
存在的路径,不会出错 ;例如调用上面的 icon.png
时可以这么用:QIcon(":/images/icon.png")
;
4.1.2 实现菜单栏、上下文菜单和工具栏 现在介绍前面没提到的 QAction
类,Qt 通过 “动作” 的概念简化了有关于菜单和工具栏的编程 ,一个动作就是一个可以添加到任意数量的菜单和工具栏上的项;
所以在 Qt 中创建菜单和工具栏包括以下几个步骤:
创建并设置动作属性,例如文本、提示、信号-槽、图标、快捷键等;
创建菜单并将动作添加到菜单上;
创建工具栏并将动作添加到工具栏上;
以本章的项目来举个栗子🌰:
Firstly ,要创建菜单栏的项并设置属性:实现上面的 MainWindow::createActions()
函数;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 void MainWindow::createActions () { newAction = new QAction (tr ("&New" ), this ); newAction->setIcon (QIcon (":/image/new.png" )); newAction->setShortcut (QKeySequence::New); newAction->setStatusTip (tr ("Create a new holySheet file" )); connect (newAction, SINGAL (triggered ()), this , SLOT (newFile ())); for (int i = 0 ; i < MaxRecentFiles; ++i) { recentFileActions[i] = new QAction (this ); recentFileActions[i]->setVisible (false ); connect (recentFileActions[i], SIGNAL (triggered ()), this , SLOT (openRecentFile ())); } exitAction = new QAction (tr ("&Exit" ), this ); exitAction->setShortcut (tr ("Ctrl+Q" )); exitAction->setStatusTip (tr ("Exit the Application" )); connect (exitAction, SIGNAL (triggered ()), this , SLOT (close ())); selectAllAction = new QAction (tr ("&All" ), this ); selectAllAction->setShortcut (QKeySequence::SelectAll); selectAllAction->setStatusTip (tr ("Select all the cells in the sheet" )); connect (selectAllAction, SIGNAL (triggered ()), holySheet, SLOT (selectAlll ())); showGridAction = new Action (tr ("&Show Grid" ), this ); showGridAction->setCheckable (true ); showGridAction->setChecked (holySheet->showGrid ()); showGridAction->setStatusTip (tr ("Show or hide the holySheet's grid" )); connect (showGridAction, SIGNAL (toggled (bool )), holySheet, SLOT (setShowGrid (bool ))); aboutAction = new Action (tr ("About" ), this ); aboutAction->setStatusTip (tr ("Show the information about the app" )); connect (aboutAction, SIGNAL (triggered ()), this , about ()); aboutQtAction = new Action (tr ("About &Qt" ), this ); aboutQtAction->setStatusTip ("Show the Qt library's About Box" ); connect (aboutQtAction, SIGNAL (triggered ()), qApp, SLOT (aboutQt ())); }
其中 QKeySequence
类提供了一套标准化的键盘快捷键的对应 enum 值,可以通过查看文档中 enum QKeySequence::StandardKey
的枚举值来找到运行平台上正确的快捷键表示;
标准键如:全选QKeySequence::SelectAll => "Ctrl+A"
,复制 Ctrl+C
等;
可惜的是,上面的 退出 就没有标准化快捷键,只能自定义:"Ctrl+Q"
;
还可以通过 QActionGroup
类来实现相互排斥 的勾选 Action
,这里不需要就不演示了;
Secondly ,需要创建菜单,并且把创建好的菜单项放进菜单中:实现 MainWindow::createMenus()
;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 void MainWindow::createMenus () { fileMenu = menuBar ()->addMenu (tr ("&File" )); fileMenu->addAction (newAction); fileMenu->addAction (openAction); fileMenu->addAction (saveAction); fileMenu->addAction (saveAsAction); separatorAction = fileMenu->addSeparator (); for (int i = 0 ; i < MaxRecentFile; ++i) fileMenu->addAction (recentFileActions[i]); fileMenu->addSeparator (); fileMenu->addSeparator (exitAction); editMenu = menuBar ()->addMenu (tr ("&Edit" )); editMenu->addAction (cutAction); editMenu->addAction (copyAction); editMenu->addAction (pasteAction); editMenu->addAction (deleteAction); selectSubMenu = editMenu->addMenu (tr ("&Select" )); selectSubMenu->addAction (selectRowAction); selectSubMenu->addAction (selectColAction); selectSubMenu->addAction (selectAllAction); editMenu->addSeparator (); editMenu->addAction (findAction); editMenu->addAction (goToCellAction); toolsMenu = menuBar ()->addMenu (tr ("&Tools" )); toolsMenu->addAction (recalculationAction); toolsMenu->addAction (sortAction); optionsMenu = menuBar ()->addMenu (tr ("&Options" )); optionsMenu->addAction (showGridAction); optionsMenu->addAction (autoRecalculation); menuBar ()->addSeparator (); helpMenu = menuBar ()->addMenu (tr ("&Help" )); helpMenu->addAction (aboutAction); helpMenu->addAction (aboutQtAction); }
Thirdly ,实现完菜单栏,再关注 上下文菜单栏(context menu 直译,或译作 “右键菜单”) :实现 MainWindow::createContextMenu()
;
实现上下文菜单的重要机制是:Qt 的上下文菜单策略(context menu policy)
任何 QWidget 都有一个与之相关联的 QAction
列表,也有上下文菜单策略枚举变量:enum Qt::ContextMenuPolicy
;当设置上下文菜单策略为枚举量 Qt::ActionsContextMenu(=2)
时,会以该 QAction 列表为 widget 的上下文菜单,当右击这个 widget 时,会展开上下文菜单;
1 2 3 4 5 6 void MainWindow::createContextMenu () { holySheet->addAction (cutAction); holySheet->addAction (copyAction); holySheet->addAction (pasteAction); holySheet->setContextMenuPolicy (Qt::ActionsContextMenu); }
还有一种设计的方法比较繁琐,不重用之前的 QAction
,即上下文菜单策略是Qt::DefaultContextMenu(=1)
时,重新实现:QWidget::contextMenuEvent()
函数,在其中创建一个 QMenu
,添加,再对该部件调用 exec()
,感兴趣可以尝试,这里不做演示;
Last but not the least ,关注工具栏 :实现 MainWindow::createToolBars()
;注意,如果你之前设置的 Action 有设置图标的话,可以直接复用,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void MainWindow::createToolBars () { fileToolBar = addToolBar (tr ("&File" )); fileToolBar->addAction (newAction); fileToolBar->addAction (openAction); fileToolBar->addAction (saveAction); editToolBar = addToolBar (tr ("&Edit" )); editToolBar->addAction (cutAction); editToolBar->addAction (copyAction); editToolBar->addAction (pasteAction); editToolBar->addSeparator (); editToolBar->addAction (findAction); editToolBar->addAction (goToCellAction); }
4.1.3 设置状态栏
什么是状态栏?大家看 Microsoft Word 中左下角每当进行一些操作时,会显示不同文字,如下,这就是状态栏:
下面实现 MainWindow::createStatusBar()
;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void MainWindow::createStatusBar () { locationLabel = new QLabel (" w999 " ); locationLabel->setAlignment (Qt::AlignHCenter); locationLabel->setMinimumSize (locationLabel->sizeHint ()); formulaLabel = new QLabel; formulaLabel->setIndent (3 ); statusBar ()->addWidget (locationLabel); statusBar ()->addWidget (formulaLabel, 1 ); connect (holySheet, SIGANL (currentCellChanged (int , int , int , int )), this , SLOT (updateStatusBar ())); connect (holySheet, SIGNAL (modified ()), this , SLOT (holySheetModified ())); updateStatusBar (); }
这部分状态栏的实现大致可以分为两个部分:
创建控件(大多是 QLabel),设置控件的布局;
初始的 locationLabel
设置内容 “ w999 ” 表示最大单元格位置,配合 locationLabel->setMinmumSize(locationLabel->sizeHint())
可以确定位置状态标签大小满足要求;
locationLabel->setAlignment(Qt::AlignHCenter)
确保文本居中对齐;
formulaLabel->setIndent(3)
表示标签内添加文本缩进,这是实现之后进行的微调,确保美观;
statusBar()->addWidget(QWidget*, int)
中第二个参数是伸展因子 ,参数为 0 表示紧贴模式(默认) ,参数为 1 表示最大占用模式(还剩多少就占多少,类似 CSS 中的 auto 参数) ;
为状态栏的变化建立连接;
先利用将要实现的 holySheet
信号 currentCellChanged(int, int, int, int)
,和更新状态栏的槽 updateStatusBar()
绑定;MainWindow::updateStatusBar()
实现如下:
1 2 3 4 void MainWindow::updateStatusBar () { locationLabel->setText (holySheet->currentLocation ()); formulaLabel->setText (holySheet->currentFormula ()); }
此外,为了实现 “已修改后会改变窗口标题” (很多软件在文件修改后,会在窗口标题加一个记号,如 *
) ,还要实现 MainWindow::holySheetModified()
:
1 2 3 4 5 void MainWindow::holySheetModified () { setWindowModified (true ); updateStatusBar (); }
注意,每个 QWidget
都有一个 windowModified
属性 ~
4.2 主窗口的底层函数实现
函数比较多,还是从窗口的菜单功能下手;
4.2.1 File 菜单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void MainWindow::newFile () { if (okToContinue ()) { holySheet->clear (); setCurrentFile ("" ); } } bool MainWindow::okToContinue () { if (isWindowModified ()) { int res = QMessageBox::warning ( this , tr ("HolySheet" ), tr ("The document has been modified.\n" "Do you want to save your changes?" ), QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel ); if (res == QMessageBox::Yes) return save (); else if (res == QMessageBox::Cancel) return false ; } return true ; }
以上值得一提的是,QMessageBox
提供了很多标准的信息对话框模板,例如:提示信息、提问、警告、严重错误,语法:QMessageBox::warning(QWidget* parent, QString title, QString message, (enum)QMessageBox::StandardButtons buttons)
;
其中枚举类型 QMessageBox::StandardButtons
含有枚举值 QMessageBox::Yes
、QMessageBox::No
、QMessageBox::Cancel
、QMessageBox::Apply
等等,有特殊需要可以参考官方文档,这里设计非常巧妙,枚举值设置为 16 进制数码,使用多个 buttons 时,利用按位或 |
(参考上面代码),达到类似 Linux 权限掩码的多选效果 ;
除了 warning
警告对话框,还有 information
提示对话框、question
提问对话框、critical
严重错误对话框;语法是相同的 ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void MainWindow::open () { if (okToContinue ()) { QString fileName = QFileDialog::getOpenFileName ( this , tr ("Open sheet" ), "." , tr ("HolySheet file (*.hs)" ) ); if (!fileName.isEmpty ()) loadFile (fileName); } } bool MainWindow::loadFile (const QString& fileName) { if (!holySheet->readFile (fileName)) { statusBar ()->showMessage (tr ("Loading canceled" ), 2000 ); return false ; } setCurrentFile (fileName); statusBar ()->showMessage (tr ("File loaded" ), 2000 ); return true ; }
借助上面的代码,再补充一些重要的点,QFileDialog
类提供各种打开文件名(“浏览”) 的对话窗口,和前面介绍的 QMessageBox
都继承于 QDialog
;比较常用的是 QFileDialog::getOpenFileName(QWdiget* parent, QString title, QString startPath, QString filter)
;
还有 QFileDialog::getSaveFileName(...)
,语法相同,之和代码会介绍;
注意:① 这里的QFileDialog 一般窗口优先级都比父窗口的高,显示在其上层;② 文件过滤器参数 filter
参数的格式是 “<description> (*.suffix)
”,如果允许多个文件类型,则用换行符分割:“<desc1> (*.suffix1)\n<desc2> (*.suffix2)
”;
QStatusBar::showMessage(QString text, int milisec)
的调用不会影响到 QStatusBar
中添加的 widget,会单独再显示消息;第二个参数 milisec
表示消息停留的毫秒数 ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 bool MainWindow::save () { if (curFile.isEmpty ()) return saveAs (); else return saveFile (curFile); } bool MainWindow::saveFile (const QString& fileName) { if (!holySheet->writeFile (fileName)) { statusBar ()->showMessage (tr ("Saving canceled" , 2000 )); return false ; } setCurrentFile (fileName); statusBar ()->showMessage (tr ("File saved" ), 2000 ); return true ; } bool MainWindow::saveAs () { QString fileName = QFileDialog::getSaveFileName ( this , tr ("Save sheet" ), "." , tr ("HolySheet file (*.hs)" ) ); if (fileName.isEmpty ()) return false ; return saveFile (fileName); }
上面的 QFileDialog::getSaveFileName
和 QFileDialog::getOpenFileName
不同的是,如果给定的文件已经存在,会自动弹出对话框提醒是否需要覆盖 ;也可以传递附加参数: QFileDialog::DontConfirmOverwrite
来改变这个行为;
1 2 3 4 5 6 7 void MainWindow::closeEvent (QCloseEvent* event) { if (okToContinue ()) { writeSettings (); event->accept (); } else event->ignore (); }
还记得之前在声明这个函数的时候的注释内容 吗?
此外,QApplication
默认 quitOnLastWindowClosed = true
,如果想要**关闭最后一个窗口后,QApplication
仍然运行(例如托盘运行),那么可以将它设置为 false
;
顺便实现内部函数 setCurrentFile
和 strippedName
。前者用于指定当前文件(curFile
私有变量)、修改窗口标题、修改 “最近文件” 列表、修改窗口是否改动的状态 ,后者用于得到文件除去后缀的名字。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void MainWindow::setCurrentFile (const QString& fileName) { curFile = fileName; setWindowModified (false ); QString shownName = tr ("Untitled" ); if (!curFile.isEmpty ()) { shownName = strippedName (curFile); recentFiles.removeAll (curFile); recentFiles.prepend (curFile); updateRecentFileActions (); } setWindowTitle (tr ("%1 [*] - %2" ).arg (shownName).arg (tr ("HolySheet" ))); } QString MainWindow::strippedName (const QString& fullFileName) { return QFileInfo (fullFileName).fileName (); }
上面提到了更新 “最近文件” 列表,还有之前说到的 “最近文件” 列表的动作绑定,现在来实现 updateRecentFileActions
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void MainWindow::updateRecentFileActions () { QMutableStringListIterator iter (recentFiles) ; while (iter.hasNext ()) { if (!QFile::exists (iter.next ())) iter.remove (); } for (int i = 0 ; i < MaxRecentFiles; ++i) { if (i < recentFiles.count ()) { QString text = tr ("&%1 %2" .arg (i + 1 ) .arg (strippedName (recentFiles[i])) ); recentFileActions[i]->setText (text); recentFileActions[i]->setData (recentFiles[i]); recentFileActions[i]->setVisible (true ); } else recentFileActions[i]->setVisible (false ); } seperatorAction->setVisible (!recentFiles.isEmpty ()); }
首先 QMutableStringIterator
可以看作是 QStringList
(recentFiles
的类型)的可变的迭代器;
其次,对于 recentFileActions[i]->setData(recentFiles[i])
这一步,其实 QAction
类的函数 setData(QVariant qvar)
对应设置了 QVariant QAction::data
数据成员,它可以保存很多种 C++ / Qt 的数据类型,包括 QString
、一切 numerical
、bool
、QStringList
等 (以后会进一步讨论);
下面实现 openRecentFile
:
1 2 3 4 5 6 void MainWindow::openRecentFile () { if (okToContinue ()) { QAction* action = qobject_cast <QAction*>(sender ()); if (action) loadFile (action ()->data ().toString ()); } }
QObject::sender()
返回 QObject*
,用于查出是哪个信号 / 动作调用了当前的槽(所以在槽函数内使用) ;这里知道调用 openRecentFile()
函数的一定是 QAction
的动作,所以强制转换到它的指针类型,并且读出 data
数据,进而找到点击的 “最近文件” 并调用 loadFile()
;
4.2.2 主窗口调用 Dialog:模态和非模态 这里会用到之前所写的 findDialog
、goToCellDialog
、sortDialog
等对话框,进行功能的拼接 ,同时会制作简单的 about
对话框。
首先是之前的 findDialog
对话框。由于希望用户可以在主窗口、子窗口间自由切换 (毕竟只是查找功能),所以 findDialog
必须是 非模态的(modeless) ——在运行的程序中,这种窗口独立于其他窗口,不会覆盖或阻挡,实现方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 void MainWindow::find () { if (!findDialog) { findDialog = new FindDialog (this ); connect (findDialog, SIGNAL (findNext (const QString&, Qt::CaseSensitivity)), holySheet, SLOT (findNext (const QString&, Qt::CaseSensitivity))); connect (findDialog, SIGNAL (findPrev (const QString&, Qt::CaseSensitivity)), holySheet, SLOT (const QString&, Qt::CaseSensitivity)); } findDialog->show (); findDialog->raise (); findDialog->activateWindow (); }
通过点击 Edit->Find
菜单来调出 find()
函数有 3 种情况:
用户第一次调用这个对话框:由于懒加载,会先创建它,再打开;
用户曾经打开过,现在是关闭(visible=false
)状态:直接 show()
就足以完成显示、置顶、激活 这 3 个状态;
用户曾经打开,仍没有关闭:这时 show()
不能完成置顶、激活这两个状态,分别需要 raise()
和 activateWindow()
来完成;
按照这个逻辑,后面 3 行还可以写成:(不过没必要 )
1 2 3 4 5 if (findDialog->isHidden ()) findDialog->show ();else { findDialog->raise (); findDialog->activateWindow (); }
再来看 goToCellDialog
,我们希望用户弹出、使用、关闭它,不希望在使用它的期间去碰主窗口,这种子窗口就是 模态的(modal) ——可弹出并阻塞应用程序的对话框:
1 2 3 4 5 6 7 8 9 void MainWindow::goToCell () { GoToCellDialog dialog (this ) ; if (dialog.exec ()) { QString str = dialog.lineEdit->text ().toUpper (); holySheet->setCurrentCell (str.mid (1 ).toInt () - 1 , str[0 ].unicode () - 'A' ); } }
注意:模态对话框不能使用 show()
,否则变成非模态对话框!应该使用 QDialog::exec()
阻塞获取对话框返回结果 (由 accept()
和 reject()
决定,QDialog::Accepted=true
、QDialog::Rejected=false
);
另外,HolySheet::setCurrentCell(int x, int y)
等待实现;
QString::mid(int)
指提取从第 int 个字符串以后的子串、toInt()
不解释,其正确性由之前 GoToCellDialog::lineEdit
中设置的 QRegExpValidator
来保证;
再来看 sortDialog
,它也是一个模态对话框,允许用户在当前选定区域 使用给定的列 进行排序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void MainWindow::sort () { SortDialog dialog (this ) ; QTableWidgetSelectionRange range = holySheet->selectedRange (); dialog.setColumnRange ('A' + range.leftColumn (), 'A' + range.rightColumn ()); if (dialog.exec ()) { HolySheetCompare compare; compare.keys[0 ] = dialog.primaryColumnCombo->currentIndex (); compare.keys[1 ] = dialog.secondaryColumnCombo->currentIndex () - 1 ; compare.keys[2 ] = dialog.tertiaryColumnCombo->currentIndex () - 1 ; compare.ascending[0 ] = (dialog.primaryColumnCombo->currentIndex () == 0 ); compare.ascending[1 ] = (dialog.secondaryColumnCombo->currentIndex () == 0 ); compare.ascending[2 ] = (dialog.tertiaryColumnCombo->currentIndex () == 0 ); holySheet->sort (compare); } }
上面我们看到了 2 个新类。一个是 Qt 原生的 QTableWidgetSelectionRange
,用来记录 QTableWidget
选中的区域信息,可以由 QTableWidget::selectRange()
得到当前选中的范围。含有 leftColumn()
、rightColumn()
方法查到区域的列;
注意:SortDialog::setColumnRange
是之前我们自己定义为了 column ComboBox
的取值范围而设置的函数。
另一个类是 HolySheetCompare
,和 HolySheet
一样,是之后我们自己会实现的类。这个类存储主键、第二键、第三键以及它们的排序顺序(keys
数组存储键的列号,ascending
数组按 bool
格式存储每个键相关顺序) ,这个对象可以被 HolySheet::sort()
使用,用于两行间的比较;
注意:QComboBox::currentIndex()
返回当前选定项的索引值,第一个为 0;
上面的 “-1” 指去除前面的 “None” 设置值。
本节最后,我们制作一个简单的 About
对话框,只需要静态函数 QMessageBox::about
就行:
1 2 3 4 5 6 7 8 9 void MainWindow::about () { QMessageBox::about (this , tr ("About HolySheet" ), tr ("<h2>HolySheet 0.1</h2>" "<p>Copyright © 2023 SJTU-XHW Inc.</p>" "<p>HolySheet is a small application that " "demonstrate QAction, QMainWindow, QMenuBar, " "QStatusBar, QTableWidget, QToolBar and many " "other Qt classes." )); }
QMessageBox::about(QWidget* parent, const QString& title, const QString& contents)
和 warning/information/critical
不同的是,它的图标取决于父控件的图标 。
4.2.3 设置的持久化 现在关注 readSettings()
、writeSettings()
函数。
1 2 3 4 5 6 7 8 9 void MainWindow::writeSettings () { QSettings settings ("SJTU-XHW Inc." , "HolySheet" ) ; settings.setValue ("geometry" , saveGeometry ()); settings.setValue ("recentFiles" , recentFiles); settings.setValue ("showGrid" , showGridAction->isChecked ()); settings.setValue ("autoRecalc" , autoRecalcAction->isChecked ()); }
上面的 QSettings
类的值设置非常类似 Python
的字典键值对,值也是用 QVariant
存储的;默认情况下,QSettings
在系统上存储方法和系统种类有关(Windows
上就存在注册表,UNIX
存在文本文件中,MacOS
存在 Core Foundation Preferences
编程接口中),详细内容如下表:
Platform
Format
Scope
Path
Windows
Native
User
HKEY_CURRENT_USER\Software\*
System
HKEY_LOCAL_MACHINE\Software\*
INI
User
%APPDATA%\*.ini
System
%COMMON_APPDATA%\*.ini
Unix
Native
User
$HOME/.config/*.conf
System
/etc/xdg/*.conf
INI
User
$HOME/.config/*.ini
System
/etc/xdg/*.ini
Mac OS X
Native
User
$HOME/Library/Preferences/com.*.plist
System
/Library/Preferences/com.*.plist
INI
User
$HOME/.config/*.ini
System
/etc/xdg/*.ini
QSettings
类的构造函数的参数分别是组织名、程序名,为的是方便设置的读取、查找 。
此外,QSettings
的设置还能以路径形式指定子键的值(如 findDialog/matchCase
)或者用 beginGroup(QString)
和 endGroup()
的形式:
1 2 3 4 settings.beginGroup ("findDialog" ); settings.setValue ("matchCase" , caseCheckBox->isChecked ()); settings.setValue ("searchBackward" , backwardCheckBox->isChecked ()); settings.endGroup ();
还有 readSettings
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void MainWindow::readSettings () { QSettings settings ("SJTU-XHW Inc." , "HolySheet" ) ; restoreGeometry (settings.value ("geometry" ).toByteArray ()); recentFiles = settings.value ("recentFiles" ).toStringList (); updateRecentActions (); bool showGrid = settings.value ("showGrid" , true ).toBool (); showGridAction->setChecked (showGrid); bool autoRecalc = settings.value ("autoRecalc" , true ).toBool (); autoRecalcAction->setChecked (autoRecalc); }
您还可以在应用程序的其他任何地方使用 QSettings
方便地查询、修改设置。
到目前为止,MainWindow
及其 UI 界面已经几乎完全实现,主要的任务就是实现 HolySheet
类和 HolySheetCompare
类了。
4.2.4 锦上添花:多文档模式 现在我们可以准备编写程序的 main
函数:
1 2 3 4 5 6 7 8 9 10 #include <QApplication> #include "mainWindow.h" int main (int argc, char * argv[]) { QApplication app (argc, argv) ; MainWindow win; win.show (); return app.exec (); }
考虑这个问题:我们想要同一时间打开、处理多个表格 ,怎么办?
肯定不能让用户重复打开程序——这是不方便的。应该像浏览器打开页面一样,提供多个窗口。这就是程序的多窗口(文档)模式 。
这时,我们就需要对 File
菜单进行一些改动,使得一个应用程序实例,能够处理多个文档 :
File->New
操作不再是使用原先存在的窗口,而是创建一个空文档窗口 ,已存在的窗口需要手动关闭;
File->Close
关闭当前的主窗口,而不是清除内容;
注:原来的 MainWindow
没有这个 Close
选项,因为是单窗口,它的作用和 Exit
相同 ;
File->Exit
关闭所有窗口,而不是关闭当前仅有的窗口。
想要具有多窗口功能,需要使用 new
来统一创建、销毁窗口。所以 main.cpp
改为:
1 2 3 4 5 6 7 8 9 10 #include <QApplication> #include "mainWindow.h" int main (int argc, char * argv[]) { QApplication app (argc, argv) ; MainWindow* win = new MainWindow; win->show (); return app.exec (); }
紧接着修改 newFile()
槽和 Actions
的组成和提示信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void MainWondow::newFile () { MainWindow* otherWin = new MainWindow; otherWin->show (); } void MainWindow::createActions () { closeAction = new QAction (tr ("&Close" ), this ); closeAction->setShortCut (QKeySequence::Close); closeAction->setStatusTip (tr ("Close this window" )); connect (closeAction, SIGNAL (triggered ()), this , SLOT (close ())); exitAction = new QAction (tr ("E&xit" ), this ); exitAction->setShortcut (tr ("Ctrl+Q" )); exitAction->setStatusTip (tr ("Exit the application" )); connect (exitAction, SIGNAL (triggered ()), qApp, SLOT (closeAllWindows ())); }
需要注意的是,QApplication
的槽 closeAllWindows()
会关闭所有在 app
循环中创建的窗口,并且不用担心释放问题,每个窗口都会调用 closeEvent()
处理。
同时需要注意另一个问题,是创建窗口过多的问题。这时就需要考虑 MainWindow
的析构问题了 。解决方法很简单:
1 2 3 4 5 MainWindow::MainWindow () { setAttribute (Qt::WA_DeleteOnClose); }
这个设置会使得所有 close()
的控件立即被析构。可是有个更需要解决的问题:“最近文件” 列表的问题 ——多个窗口需要共享一个 “最近文件” 的列表。
这个问题可以通过将 recentFiles
改成静态变量的方法解决,读者可以自行思考。
实现本节 “多文档” 的目标不止这一种,还可以采用 Qt
的 MDI
(multiple document interface)管理方法,以后介绍。
4.2.5 锦上添花:程序启动画面 这个需求可能是因为甲方的要求,又或是要掩饰程序启动慢的事实(手动狗头);很简单,只需要在 main
函数中使用 QSplashScreen
类就能解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 int main (int argc, char * argv[]) { QApplication app (argc, argv) ; QSplashScreen* splash = new QSplashScreen; splash->setPixmap (QPixmap (":/images/splash.png" )); splash->show (); Qt::Alignment topRight = Qt::AlignRight | Qt::AlignTop; splash->showMessage (QObject::tr ("Setting up the main window..." ), topRight, Qt::black); MainWindow win; splash->showMessage (QObject::tr ("Testing windows (5s)..." ), topRight, Qt::black); QTime timer; timer.start (); while (timer.elapsed () < 5000 ) app.processEvents (); win.show (); splash->finish (&win); delete splash; return app.exec (); }
中央窗口部件常见的设计方法有下面几种:
使用一个标准的 Qt 窗口部件 (或继承于其的控件)作为 centralWidget
,例如 QTableWidget
、QTextEdit
等,本工程就是用这种方法;
使用一个自定义窗口部件 作为 centralWidget
,以后会介绍怎么自定义窗口部件(继承得到的不叫自定义部件);
使用一个带布局管理器的普通 QWidget
作为 centralWidget
,这也是初学者最喜欢用的方法;
使用切分窗口(QSplitter
) ,利用切分条(splitter handle
)控制它们的尺寸;
使用MDI
。上一节说了,以后再讨论。
本项目使用的是标准 Qt 窗口部件 QTableWidget
,并采用继承的方法来给 HolySheet
类添加一些必要的功能。先搞清 QTableWidget
的继承关系:
1 2 3 4 QObject -> QWidget -> QTableView -> QTableWidget -> HolySheet(自定义) // 这是个纯粹的数据类,就是存放数据、为 QTableWidget 类服务 QTableWidgetItem -> Cell(自定义)
再了解一下 QTableWidget
类对象的组成:
4.3.1 表格的定义 最后开始设计 HolySheet
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 #pragma once #include <QtWidgets/QTableWidget> #include "Cell.h" namespace holysheet { constexpr int MagicNumber = 0x7F51C883 ; constexpr int RowCount = 999 ; constexpr int ColumnCount = 26 ; }; using namespace holysheet;class HolySheetCompare ;class HolySheet : public QTableWidget { Q_OBJECT public : HolySheet (QWidget* parent = nullptr ); bool isAutoRecalc () const { return autoRecalc; } QString currentLocation () const ; QString currentFormula () const ; QTableWidgetSelectionRange selectedRange () const ; void clear () ; bool readFile (const QString& fileName) ; bool writeFile (const QString& fileName) ; void sort (const HolySheetCompare& compare) ; public slot: void cut () ; void copy () ; void paste () ; void del () ; void selectCurrentRow () ; void selectCurrentColumn () ; void recalculate () ; void setAutoRecalculate (bool recalc) ; void findNext (const QString& text, Qt::CaseSensitivity cs) ; void findPrev (const QString& text, Qt::CaseSensitivity cs) ; signals: void modified () ; private slots: void somethingChanged () ; private : bool autoRecalc; Cell* cell (int row, int col) const ; QString text (int row, int col) const ; QString formula (int row, int col) const ; void setFormula (int row, int col, const QString& formula) ; };
4.3.2 表格的 “文本” 和 “公式” 下面正式开始实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 #include "HolySheet.h" HolySheet::HolySheet (QWidget* parent) : QTableWidget (parent) { autoRecalc = true ; setItemPrototype (new Cell); setSelectionMode (ContiguousSelection); connect (this , SIGNAL (itemChanged (QTableWidgetItem*)), this , SLOT (somethingChanged ())); clear (); } void HolySheet::clear () { setRowCount (0 ); setColumnCount (0 ); setRowCount (RowCount); setColumnCount (ColumnCount); for (int i = 0 ; i < ColumnCount; ++i) { QTableWidgetItem* item = new QTableWidgetItem; item->setText (QString (QChar ('A' + i))); setHorizontalHeaderItem (i, item); } setCurrentCell (0 , 0 ); }
声明中的 cell()
函数是为了给定行列,返回一个 Cell
指针,目的和 QTableWidget::item()
取 QTableWidgetItem
一样。只不过之前设置了每个格子类型为 Cell
,所以这里自定义:
1 2 3 Cell* HolySheet::cell (int row, int col) const { return static_cast <Cell*>(item (row, col)); }
更方便的是,可以直接取单元格的数据为字符串、公式:
1 2 3 4 5 6 7 8 9 10 11 QString HolySheet::text (int row, int col) const { Cell* c = cell (row, col); if (c) return c->text (); else return "" ; } QString HolySheet::formula (int row, int col) const { Cell* c = cell (row, col); if (c) return c->formula(); else return "" ; }
⚠ 注意:“公式” 和 “文本” 数据在每个 Cell
中存储在不同地方(机理和 QTableWidgetItem
类有关,由 role
参数控制,等实现 Cell
类再说。目前只需要知道看文本用 text()
、看公式 formula()
) ,而公式数据可以按照计算结果来修改本格的文本数据。
这里需要搞清楚,很多情况下,公式和文本是相同的,它的处理规则 如下:
这里 由公式计算出值 的过程会由 Cell
类完成,HolySheet
类不会涉及。
上面实现的是取数据的函数,下面实现设定数据的函数:
1 2 3 4 5 6 7 8 9 10 void HolySheet::setFormula (int row, int col, const QString& formula) { Cell* c = cell (row, col); if (!c) { c = new Cell; setItem (row, col, c); } c->setFormula (formula); }
细心的读者会发现,这里只有 setFormula
,没有 setText
,刚刚不是说两个分开存储吗?对,这里还是和 QTableWidgetItem
类有关。我们会在 “QTableWidgetItem
机理简介” 一节进行解释 。
另外能看出来的是,我们存储表并不是使用二维数组或字符串列表,而是存储为项(item) ,有助于节省空间、加快运行速度等。这种方法在 QListWidget
和 QTreeWidget
类中也能看到(对应 QListWidgetItem
和 QTreeWidgetItem
类);
此外,对于更大数据量的应用场景,Qt 还支持模型/视图类(model/view),以后介绍。
再来实现其他用到的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 QString HolySheet::currentLocation () const { return QString ('A' + currentColumn () + QString::number (currentRow () + 1 )); } QString HolySheet::currentFormula () const { return formula(currentRow (), currentColumn ()); } void HolySheet::somethingChanged () { if (autoRecalc) recalculate (); emit modified () ; }
4.3.3 表格数据的存取 再来看表数据的存储和读取 的问题。我们要用一种自定义的二进制格式 来实现 HolySheet
文件的读取和存储。这时就用到了 QFile
类和 QDataStream
类 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 bool HolySheet::writeFile (const QString& fileName) { QFile file (fileName) ; if (!file.open (QIODevice::WriteOnly)) { QMessageBox::warning (this , tr ("HolySheet" ), tr ("Cannot write file %1:\n%2." ) .arg (file.fileName ()) .arg (file.errorString ())); return false ; } QDataStream out (&file) ; out.setVersion (QDataStream::Qt_5_14); out << quint32 (MagicNumber); QApplication::setOverrideCursor (Qt::WaitCursor); for (int row = 0 ; row < RowCount; ++row) { for (int col = 0 ; col < ColumnCount; ++col) { QString str = formula(row, col); if (!str.isEmpty ()) out << quint16 (row) << quint16 (col) << str; } } QApplication::restoreOverrideCursor (); return true ; }
注意,QDataStream
模仿了标准 C++ 的流(stream
)类,都使用 >>/<<
运算符来输入输出流,例如:in >> x >> y;
和 out << x << y;
。
这里还考虑到 C++ 的基本类型在不同平台下的空间大小不同 ,所以将它们强制转换为 qint8/quint8/...(16、32)/qint64/quint64
,其中 quint64
最安全,但占用空间也比较多,所以结合实际数据范围,上面的代码以 quint16
为例。
在上面的例子中,HolySheet
程序的文件(*.hs
)存储格式为:
1 MagicNumber(混淆码,32 bits, 4 Bytes) | row(16 bits) | column(16 bits) | string (32 bits) | row | column | string | ...
而具体存储方式由 QDataStream
内部决定。例如,quint16
会被 QDataStream
按小端序 存成 2 Bytes,QString
会被存成 字符串长度 + Unicode字符
形式;
而且不同版本的 Qt ,存储方式不同 ,这也是为什么上面要指定 QDataStream
的版本(setVersion
);
QDataStream
除了使用在 QFile
上,还能用在 QBuffer
、QProcess
、QTcpSocket
、QUdpSocket
、QSslSocket
等类中。
如果仅仅是读取文本,还可以用 QTextStream
来代替 QDataStream
。以后会深入介绍。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 bool HolySheet::readFile (const QString& fileName) { QFile file (fileName) ; if (!file.open (QIODevice::ReadOnly)) { QMessageBox::warning (this , tr ("HolySheet" ), tr ("Cannot read file %1:\n%2." ) .arg (file.fileName ()) .arg (file.errorString ())); return false ; } QDataStream in (&file) ; in.setVersion (QDataStream::Qt_5_14); quint32 magic; in >> magic; if (magic != MagicNumber) { QMessageBox::warning (this , tr ("HolySheet" ), tr ("The file is broken / not a HolySheet file." )); return false ; } clear (); quint16 row, column; QString str; QApplication::setOverrideCursor (Qt::WaitCursor); while (!in.atEnd ()) { in >> row >> column >> str; setFormula (row, column, str); } QApplication::restoreOverrideCursor (); return true ; }
4.3.4 表格编辑功能 到现在为止,HolySheet
除 “公式处理功能” 以外的其他功能已经基本具备(准确地说是接口做好了,可以当作 Cell
类已经实现),现在把 Edit
菜单中对应的诸如 复制、粘贴、剪切、删除 等功能补充完全:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 void HolySheet::cut () { copy (); del (); } void HolySheet::copy () { QTableWidgetSelectionRange range = selectedRange (); QString str; for (int i = 0 ; i < range.rowCount (); ++i) { if (i > 0 ) str += "\n" ; for (int j = 0 ; j < range.columnCount (); ++j) { if (j > 0 ) str += "\t" ; str += formula(range.topRow () + i, range.leftColumn () + j); } } QApplication::clipboard ()->setText (str); }
上面的复制方法的结果认为,每列每格间隔一个制表符(\t
),每行间格一个换行符(\n
)——这是几乎全球通用的格式 ,Microsoft Excel
也这么用,所以可以直接粘贴到其他多数软件上。
对于粘贴、删除 (剪切包含在其中)的操作,会导致modified :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 void HolySheet::paste () { QTableWidgetSelectionRange range = selectedRange (); QString str = QApplication::clipboard ()->text (); QStringList rows = str.split ('\n' ); int numRows = rows.count (); int numColumns = rows.first ().count ('\t' ) + 1 ; if (range.rowCount () * range.columnCount () != 1 && (range.rowCount () != numRows || range.columnCount () != numColumn)) { QMessageBox::warning (this , tr ("HolySheet" ), tr ("The contents in the clipboard cannot be" " pasted because the contents and the " "selected area aren't the same size." )); return ; } for (int i = 0 ; i < numRows; ++i) { QStringList columns = rows[i].split ('\t' ); for (int j = 0 ; j < numColumns; ++j) { int row = range.topRow () + i; int column = range.leftColumn () + j; if (row < RowCount && column < ColumnCount) setFormula (row, column, columns[j]); } } somethingChanged (); } void HolySheet::del () { QList<QTableWidgetItem*> items = selectedItems (); if (!items.isEmpty ()) { foreach (QTableWidgetItem* item, items) delete item; somethingChanged (); } }
上面用了那么多次自定义的 selectedRange()
函数,现在看看如何实现:
1 2 3 4 5 6 7 8 9 10 QTableWidgetSelectRange HolySheet::selectedRange () const { QList<QTableWidgetSelectionRange> ranges = selectedRanges (); if (ranges.isEmpty ()) return QTableWidgetSelectRange (); return range.first (); }
再来看 MainWindow
的菜单栏中 select
子菜单中,有两个功能(另外一个 selectAll
函数就是 QTableWidget
的原生函数,不用写,直接 connect
就行),一个是 select column
,另一个是 select row
,它们就是选定当前选中单元格所在的列 / 行,只需要交给原生的 QTableWidget::selectColumn/selectRow(int)
就行:
1 2 3 4 5 6 7 void HolySheet::selectCurrentRow () { selectRow (currentRow ()); } void HolySheet::selectCurrentColumn () { selectColumn (currentColumn ()); }
再实现心心念念的 findNext
、findPrev
(早在 findDialog
的编写上就看到了):
注意:无论是向前找,还是向后找,都是从当前选中位置开始的,这是共识
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 void HolySheet::findNext (const QString& str, Qt::CaseSensitivity cs) { int row = currentRow (), column = currentColumn () + 1 ; while (row < RowCount) { while (column < ColumnCount) { if (text (row, column).contains (str, cs)) { clearSelection (); setCurrentCell (row, column); activateWindow (); return ; } ++column; } column = 0 ; ++row; } QApplication::beep (); } void HolySheet::findPrev (const QString& str, Qt::CaseSensitivity cs) { int row = currentRow (), column = currentColumn () - 1 ; while (row >= 0 ) { while (column >= 0 ) { if (text (row, column).contains (str, cs)) { clearSelection (); setCurrentCell (row, column); activateWindow (); return ; } --column; } column = ColumnCount - 1 ; --row; } QApplication::beep (); }
再来实现支持 MainWindow
的 Tools
和 Options
菜单的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void HolySheet::recalculate () { for (int row = 0 ; row < RowCount; ++row) { for (int column = 0 ; column < ColumnCount; ++column) { if (cell (row, column)) cell (row, column)->setDirty (); } } viewport ()->update (); } void HolySheet::setAutoRecalculate (bool recalc) { autoRecalc = recalc; if (autoRecalc) recalculate (); }
4.3.5 表格的排序功能 接下来解释 HolySheetCompare
类 和 HolySheet::sort()
排序的实现原理。
首先,我们要想起 sort
操作的用户使用方法就是:先选中一个区域,然后在弹出的 sortDialog
对话框中选择第一、二、三键(比较的列数)和升降序的要求,最终程序按数据和要求排序 ;
我们简单设计给表格排序的算法 :
将选中的区域分割成 行列表 (每一行在一个列表中,不同列的作为列表的不同元素),对这些行列表进行编号;
使用 Qt 自带的 qStableSort
算法(看到名字就知道是稳定的排序算法),它的参数为:
1 2 3 4 5 6 template <class elemT >qStableSort ( QList<elemT>::iterator iter_start, QList<elemT>::iterator iter_end, bool (comp)(elemT obj1, elemT obj2) )
其中 comp
函数指针一定要对传入的所有可能的 elemT
类数据都能比较 ,要求:如果第一参数小于第二参数,则返回 true
,反之无论如何返回 false
,那么最后是升序排序 ;
如果想降序,只要让 comp
指向的函数在大于关系时返回 true
就行;
这里利用传入的将行列表作为排序的最小单元,所以传给 qStableSort
的第一、二参数都会是 QList<QStringList>
类型;第三个参数的函数指针是设计的核心 ,类型要求:bool (comp)(QStringList, QStringList)
,并且能够按 QStringList
中指定的键进行依此排序 ;
最后只需把排序好的 QList<QStringList>
重新写入当前列表,并且标记为 “modified” 就完成了。
为了完成上面的第二步的要求,我们设计出了 HolySheetCompare
类,使其不仅能够保存用户排序的需求信息,还能并且能够按用户指定的键进行元素先后判别 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 namespace hsCompare { constexpr int KeyCount = 3 ; }; using namespace hsCompare;class HolySheetCompare {public : bool operator () (const QStringList& row1, const QStringList& row2) const ; int keys[KeyCount]; bool ascending[KeyCount]; };
这里是实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 bool HolySheetCompare::operator () (const QStringList& row1, const QStringList& row2) const { for (int i = 0 ; i < KeyCount; ++i) { int column = keys[i]; if (column != -1 ) { if (row1[column] != row2[column]) { if (ascending[i]) return row1[column] < row2[column]; else return row1[column] > row2[column]; } } } return false ; }
这样,我们就能完整实现 HolySheet::sort
函数了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 void HolySheeet::sort (const HolySheetCompare& compare) { QList<QStringList> rows; QTableWidgetSelectionRange range = selectedRange (); int i; for (i = 0 ; i < range.rowCount (); ++i) { QStringList curRow; for (int j = 0 ; j < range.columnCount (); ++i) curRow.append (formula(range.topRow () + i, range.leftColumn () + j)); rows.append (curRow); } qStableSort (rows.begin (), rows.end (), compare); for (i = 0 ; i < range.rowCount (); ++i) { for (int j = 0 ; j < range.columnCount (); ++j) setFormula (range.topRow () + i, range.leftColumn () + j, rows[i][j]); } clearSelection (); somethingChanged (); }
4.3.6 表格的公式计算实现 到此为止,HolySheet
的全部要求都实现完毕,现在进行 Cell
的定义和实现,一举解决公式计算问题。
根据上面 HolySheet
类对于 Cell
的请求,Cell
的定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #pragma once #include <QtWidgets/QTableWidgetItem> class Cell : public QTableWidgetItem {public : Cell (); void setFormula (const QString& formula) ; QString formula () const ; void setDirty () ; QTableWidgetItem* clone () const override ; void setData (int role, const QVariant& value) override ; QVariant data (int role) const override ; private : QVariant value () const ; mutable QVariant cachedValue; mutable bool cacheIsDirty; };
首先解释基础 C++ 知识(和 Qt 没关系)——什么是 mutable
关键字。在 C++ 面向对象中,const(constant)
和 mutable
是一对含义恰好相反的关键字。
考虑这个场景:如果我有个函数(func_1
),只想改变一个数据成员(A
),其他(B/C/D/...
)变量都不允许它改变,想要保护起来。 为了强调 “保护数据成员” 这一要求,我们可能会想把 func_1
声明为 const
常量函数,但是怎么能改到 A
呢?
没错!把 A
声明为 mutable
变量就行了!所谓 mutable
关键字的作用就是:类中的、凡被此关键字修饰的数据成员,都可以被常量函数修改! 这样做可以起到一个作用:强调 “这个静态函数不会修改其他任何数据” 的特点。
1 2 3 4 5 6 #include "Cell.h" Cell::Cell () { setDirty (); } void Cell::setDirty () { cacheIsDirty = true ; }
这里没必要传递父对象,因为当你 new
一个 Cell
对象,并且用 setItem()
插入到 QTableWidget
当中时,QTableWidget
会自动获得对该 Cell
对象的控制权,也会自动帮您析构 。
现在也不得不回答,之前提出的问题:“为什么在 HolySheet
、Cell
里面都只有 setFormula
,没有 setText
,但却说 ‘公式和文本分开存储’ 呢?”
实际上,在上面 Cell.h
的声明中也能看到,Cell
和它的父类都采用 role
参数来控制 Data
的方法,而这就是 QTableWidgetItem
的设计。
在 Qt 中有几个枚举值:Qt::DisplayRole
、Qt::EditRole
、Qt::TextAlignmentRole
,用来指定 QTableWidgetItem
存储数据的 “不同模式”:
Qt::EditRole
表示编辑模式,指的是单元格的原始内容(raw);
Qt::DisplayRole
表示显示模式,指的是单元格的显示内容;
Qt::TextAlignmentRole
表示对齐样式模式,指的是单元格的对齐方式;
无论是原式内容,还是显示内容,都由 QTableWidgetItem::data(Qt::role)
这个原生原生函数统一返回。在默认情况下,QTableWidgetItem
的显示内容和原始内容是一模一样的。
但是我们的程序是模仿 Excel 表格,要处理公式——于是乎,我们自定义了 data(Qt::role)
函数,使得公式所在单元格的显示内容和原始内容可能不一样 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 QVariant Cell::data (int role) const { if (role == Qt::DisplayRole) { if (value ().isValid ()) return value ().toString (); else return "####" ; } else if (role == Qt::TextAlignmentRole) { if (value ().type () == QVariant::String) { return int (Qt::AlignLeft | Qt::AlignVCenter); } else { return int (Qt::AlignRight | Qt::AlignVCenter); } } else { return QTableWidgetItem::data (role); } }
首先解释一下上面的 if (value().isValid())
:这个 Cell::value()
是等会会实现的自定义函数,用来从 EditRole 的原始内容计算出 DisplayRole 的展示内容,换言之,这个函数用来计算公式的!!!
只有当 “文本是公式,并且不符合语法“的,value()
函数才会返回 QVariant()
,其他的无论是纯文本(原封不动) ,还是符合语法的公式(解析得到答案) 都会返回有数据的 QVariant
实例。
大家可能会奇怪,QVariant::isValid()
是用来判断什么有效的?事实上,只有使用 QVariant
的默认构造函数构造(无参数)出来的实例,其 isValid()
返回值才是 false
,其他含有数据的实例都是 true
。
现在就能解释 “为什么只有 setFormula
没有 setText
” 的问题。
因为 QTableWidgetItem::text()
是原生函数(这里 Cell
类没有覆写,因为满足要求),在内部会调用 QTableWidgetItem::data(Qt::DisplayRole)
。也就是说:text()
获取的是 “展示模式” 的数据,formula()
获取的是 原始数据 。
所以,我们只需要设置原始数据(Cell::setFormula
,更底层调用 Cell::setData
)、设置原始数据的计算方式(Cell::value
及相关函数),就能让展示模式、原始内容各自呈现出我们想要的内容!
既然我们现在知道了 setFormula
、formula
、value
、 setData
的作用,现在我们分别实现它们:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void Cell::setFormula (const QString& formula) { setData (Qt::EditRole, formula); } QString Cell::formula () const { return data (Qt::EditRole).toString (); } void Cell::setData (int role, const QVariant& value) { QTableWidgetItem::setData (role, value); if (role == Qt::EditRole) setDirty (); }
对于 Cell::setData
的 if (role == Qt::EditRole)
,有同学可能会问,为啥要判断一下?既然修改了,直接 setDirty
不就行?其实不是这样。
前面说了,用户只能改原始数据 (Qt::EditRole
);而展示数据(Qt::DisplayRole
)是程序内部计算公式 / 纯文本的时候才会更改。setDirty
只需要,也只能 标记用户的修改行为。一旦标记了机器的修改行为,轻则浪费系统资源,重则会陷入 “机器更新展示数据 -> setDirty
-> 机器更新展示数据 -> …” 的死循环 中。
继续:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 #include "calc.h" QVariant Cell::value () const { if (cacheIsDirty) { cacheIsDirty = false ; QString formulaStr = formula(); if (formulaStr.startsWith ('\'' )) cachedValue = formulaStr.mid (1 ); else if (formulaStr.startsWith ('=' )) { cachedValue = QVariant (); QRegularExpression exp ("([a-zA-Z][1-9][0-9]{0,2})" ) ; QRegularExpressionMatch curMatch; while (true ) { curMatch = exp.match (formulaStr); if (!curMatch.hasMatch ()) break ; QString var = curMatch.captured (1 ); QChar tmp = var[0 ] - 'A' ; int column = tmp.toInt (), row = var.mid (1 ).toInt () - 1 ; Cell* varCell = static_cast <Cell*>( tableWidget ()->item (row, column) ); QString cur; if (varCell) cur = varCell->text (); else cur = "0.0" ; formulaStr.replace ( formulaStr.indexOf (var), var.size (), cur ); } try { calc calculator (formulaStr.toStdString().c_str()) ; cachedValue = calculator.getResult (); } catch (exprErr err) { QMessageBox::warning (this , tr ("HolySheet" ), tr (err.what ())); } } else { bool ok; double d = formulaStr.toDouble (&ok); if (ok) cachedValue = d; else cachedValue = formulaStr; } } return cachedValue; }
最后可能需要覆写一个父类里的函数 QTableWidget::clone()
,它的作用是,当用户在表格中从未创建过的单元格中写入数据时,自动调用 clone()
传递给之前的 QTableWidget::setItemPrototype()
,这里需要用 Cell
类 new
,不能用父类来:
1 2 3 QTableWidgetItem* Cell::clone () const { return new Cell (*this ); }
这样返回的虽然是父类指针,但是指向子类的对象,在 setItemPrototype
函数里会转换回去,所以满足要求。
至此,Cell
、HolySheet
类已经全部完成,现在只需要回到 MainWindow
中,把剩下还没加入的一些 Action
添加进去,整个程序就完成了 !
如果你不是 IDE 用户,别忘了写一写 CMakeLists.txt/Makefile
或者运行运行 qmake
,把项目跑起来吧 ~
4.4 章末总结 & 下文预告 下面是本人总结的 Qt 5 类图,和上次总结相比,非常非常地长。需要 *.xmind/*.pdf/*.png
格式的同学可以联系本人 ~
同系列下一篇文章预告:将会是关于 Qt 的 event
(事件)、图形绘制 和 游戏的内容。