QTableView 是 Qt 的一個 model / view 的表格元件(官方文件),如果搭配 QAbstractItemModel 來使用的話,其實還算簡單好用,甚至可以很簡單地做到多個表格間的資料同步。
而如果需要表格內不只想要顯示文字、而希望可以使用一般的 QWidget 的話呢,他其實也有提供 setIndexWidget() 這個函式(文件),可以針對每一個欄位,來設定要使用的 widget。
或者另一個選擇,就是放棄使用 QAbstractItemModel,改用 QTableWidget(參考),也是一個方法。
這樣的使用方法,在一般狀況下都算是相對簡單,而且沒什麼問題。
不過,由於 Heresy 自己是有透過 QGraphicsScene 來做 UI 的 offscreen 繪製(這部分可以參考之前的《使用 QGraphicsScene 繪製 widget、產生 OpenGL Texture》),所以…就又踩到一個 Qt 的地雷了。
這邊碰到的問題,是當使用 QGraphicsScene 來畫圖形介面的時候,拉動卷軸的時候,QTableView 的一般內容都會正常地跟著拉動、更新畫面,但是透過 setIndexWidget() 設定的 QWidget 卻不會跟著動、只會留在原來的位置。
下面就是 Heresy 拿之前用來測試 Qt OpenGL 字型的程式(參考)改出來的測試畫面。
上面的圖片內左邊的「Group 2」是正常使用 QTableView 的結果,看起來很正常;右邊則是把同一個 widget 用 QGraphicsScene 來繪製,這邊可以看到,「Visibility」這欄的按鈕,在把卷軸拉到底的時候,位置已經整個錯位、沒辦法對齊了。
而「Name」這欄由於是直接使用 QTableView 內建的顯示方法,所以是沒問題的。
這樣的問題,在 QTableWidget 也會發生。查了一下後,也可以看到這是一個從 Qt 4.6 存在至今、已經有人回報過的問題(官網)…
根據下面的回應,這個問題應該是由於 QTableView 的底層、QAbstractItemView 不會去處理 QGraphicsProxyWidget 的關係…而這個問題,看來短時間也不可能解了。
要解決這個問題的話,看來目前比較可行的方法,就是不要試著在表格裡用真正的 widget,而是透過 Qt 針對 model / view 框架提供的「delegate」的架構,來處理表格內欄位的顯示以及事件。
Qt 這邊提供的 delegate 類別,主要是 QItemDelegate(文件), 不過官方也建議最好是直接使用 QStyledItemDelegate(文件)這個有處理 style 的版本,會更合適。
要使用 Qt Model / View 的 delegate 的話,首先就是要繼承 QStyledItemDelegate、定義出屬於自己的 delegate class,然後針對自己的需求,去重新實作他的函式。
之後,則是透過 QAbstractItemView 提供的 setItemDelegate()、setItemDelegateForColumn()、setItemDelegateForRow() 這三種不同的函式,來設定要怎麼使用指定的 delegate。
其中,setItemDelegate() 就是讓這個 delegate 適用於全部的欄位,setItemDelegateForColumn() 則是只針對指定的欄位設定 delegate、setItemDelegateForRow() 則是只針對特定的列設定(重複設定時 row 優先)。
至於要怎麼寫自己的 delegate 呢?Qt 官方有提供一個「Star Delegate」、也就是五顆星評比型式的 delegate 範例可以參考(網址)。
而像 Heresy 這邊,假設只是要把其中一列以按鈕的形式來顯示的話呢?其實可以更簡單一點,寫成類似下面的形式:
class QTextButtonDelegate : public QStyledItemDelegate { Q_OBJECT public: QTextButtonDelegate(QObject* pParent = nullptr) : QStyledItemDelegate(pParent){} virtual void paint( QPainter* pPainter, const QStyleOptionViewItem qOption, const QModelIndex &qIndex) const override; virtual bool editorEvent( QEvent* pEvent, QAbstractItemModel* pModel, const QStyleOptionViewItem& rOption, const QModelIndex& rIndex) override; signals: void clicked(QStandardItem* pItem); };
其中,paint() 這個函式是實際用來繪製的函式,editorEvent() 則是用來處理事件、讓使用者按下按鈕時,會去觸發 clicked 這個 signal 用的。
而 paint() 的內容,這邊簡單寫成:
void QTextButtonDelegate::paint( QPainter* pPainter, const QStyleOptionViewItem& qOption, const QModelIndex& qIndex) const { QStyleOptionButton button; button.rect = qOption.rect; button.text = qIndex.data( Qt::DisplayRole).toString(); button.state = QStyle::State_Enabled; QApplication::style()->drawControl(QStyle::CE_PushButton, &button, pPainter); }
這邊基本上就是簡單地定義一個 QStyleOptionButton(文件) ,然後透過呼叫 QApplication::style() 來取得現在的程式的介面風格,然後使用 QStyle::drawControl() 來把按鈕畫出來了~
而其中,按鈕的文字的部分,這邊是透過
qIndex.data( Qt::DisplayRole).toString()
這行來取得的。
他基本上就是去取得目前要繪製的項目的索引值(qIndex)的 Qt::DisplayRole 的資料,並將他轉換成 QString。
要這樣寫的原因,主要是因為 delegate 的物件基本上會是共用的,同一個表格裡面會共用幾個 delegate,所以不能把欄位的資料以資料成員的形式儲存在 delegate 的物件中。
而如果要存個別的資料,基本上是可以透過把資料儲存在 item 中來做。Heresy 這邊為了方便操作,所以都是採用 QStandardItemModel 來當作資料模型,如此一來表格內每一個欄位就都是一個 QStandardItem;然後透過 setData() 這個函式,就可以將各種型別的資料,封包成 QVariant 的形式、儲存在 item 中。
即便是要儲存的資料不只一個,也可以透過指定不同的 role、來指定資料的存取。像上面的例子裡,就是去讀取 Qt::DisplayRole 的資料。
而如果是要畫其他的元件,也可以以此類推來修改。
至於事件處理的部分,這邊也很簡單地、只去處理放開滑鼠按鍵的狀況,其內容如下:
bool QTextButtonDelegate::editorEvent(QEvent* pEvent, QAbstractItemModel* pModel, const QStyleOptionViewItem& rOption, const QModelIndex& rIndex) { if (pEvent->type() == QEvent::MouseButtonRelease) { emit clicked(((QStandardItemModel*)pModel)->itemFromIndex(rIndex)); return true; } return false; }
可以看到,這邊基本上就是只要是有滑鼠按鈕放開的事件,他就會去執行 clicked 這個 signal、而傳遞的參數,則是為了 Heresy 這邊外部的設計,所以先轉換成 QStandardItem 了。
如果沒有要使用 QStandardItem 的話,其實也可以傳遞其他自己需要的資訊(例如 rIndex),這樣就不會把整個 delegate 綁成只有 QStandardItemModel 能用了。
而要使用這個 delegate,則可以寫成:
QTextButtonDelegate* pTBD = new QTextButtonDelegate(); ui.tableView->setItemDelegateForColumn(0, pTBD); connect(pTBD, &QTextButtonDelegate::clicked, [](QStandardItem* pItem) { // Do some thing });
這樣的話,就會把 ui.tableView 的第一欄都用文字按鈕的形式來顯示,並在按下之後,可以讓他去做某些事情了~
上面基本上只是顯示文字的按鈕。之後 Heresy 則也是針對自己的需要,進一步去實作了有狀態、僅以圖示顯示的開關式按鈕。
左邊就是最後結果的截圖。這邊的 delegate 就是設計成一個 delegate 會有兩種圖案,可以根據 item 的狀態、來決定要畫哪一種、藉此明確地表現出按鈕當下的狀態。
這邊的程式和前面的 QTextButtonDelegate 大致相同,他的類別定義是:
class QCheckButtonDelegate : public QStyledItemDelegate { Q_OBJECT public: // Constructor. aFiles: two image file name for status icon QCheckButtonDelegate(const QStringList& aFiles, QObject* pParent = nullptr) : QStyledItemDelegate(pParent) { loadImages(aFiles); } virtual void paint(QPainter* pPainter, const QStyleOptionViewItem& qOption, const QModelIndex &qIndex) const override; virtual bool editorEvent(QEvent* pEvent, QAbstractItemModel* pModel, const QStyleOptionViewItem& rOption, const QModelIndex& rIndex) override; virtual void loadImages( const QStringList& aFiles ); signals: void clicked(QStandardItem* pItem, bool bChecked); protected: QIcon m_aIcon[2]; };
而 paint() 的內容則是:
void QCheckButtonDelegate::paint(QPainter* pPainter, const QStyleOptionViewItem & qOption, const QModelIndex & qIndex) const { QStyleOptionButton button; button.rect = qOption.rect; if(qIndex.data(Qt::UserRole).toBool()) button.icon = m_aIcon[1]; else button.icon = m_aIcon[0]; button.iconSize = button.rect.size() * 0.8; button.state = QStyle::State_Enabled; QApplication::style()->drawControl(QStyle::CE_PushButton, &button, pPainter); }
在這邊,並沒有去設定 QStyleOptionButton 的文字,取而代之的則是指定了他的 icon。在繪製時,則是會去讀取這個欄位的 Qt::UserRole 的資料(型別是 bool),來判斷要畫哪一個 icon。
而這邊的兩個 icon 是可以讓 delegate 共用的,所以就變成是 delegate 的資料成員,在初始化的時候就讀取了~
至於 editorEvent() 的部分,也會在觸發事件的時候、去修改 Qt::UserRole 的資料,如此才能在繪製的時候,顯示正確的圖示。
bool QCheckButtonDelegate::editorEvent(QEvent* pEvent, QAbstractItemModel* pModel, const QStyleOptionViewItem& rOption, const QModelIndex& rIndex) { if (pEvent->type() == QEvent::MouseButtonRelease) { QStandardItem* pItem = ((QStandardItemModel*)pModel)->itemFromIndex(rIndex); bool bChecked = !pItem->data(Qt::UserRole).toBool(); pItem->setData(bChecked, Qt::UserRole); emit clicked(pItem, bChecked); return true; } return false; }
而為了方便,這邊在觸發 signal 的時候,也會把目前的選取狀況(bChecked)一起當作參數傳送出去。
另外,由於 QTableView 預設是會提供編輯模式的,以文字的內容來說,在欄位上用滑鼠點兩下,就會進入文字編輯模式。
所以以上面的按鈕設計來說,雖然按一下按鈕會觸發事件,但是如果連點兩下,則也會變成編輯文字…
所以,在建立資料的時候,針對這類的 item,也要把他設定成不可編輯的狀態,來避免這樣的問題發生。
如果是以 QStandardItem 來說,它本身就有提供一個 setEditable() 的函式,可以設定是否可以編輯了;所以只要將每個 item 都設定成不可編輯,按鈕就不會在滑鼠點兩下的時候出包了~
當然,這樣的作法並不算完整。
Qt 的各式元件,其實在不同的狀態下,繪製的樣子其實是有不同的;以按鈕來說,當滑鼠移到按鈕上、按下,其實都會有不同的視覺效果。
但是在上面的程式中所繪出的按鈕,實際上並沒有去處理這麼細節的變化。如果真的要做的話,應該是也可以將 editorEvent() 寫得更完善、並將狀態儲存下來,變成繪製時的參數。(要做的夠好很難就是了)
不過 Heresy 這邊也只是先求能動,所以就沒做到這麼細了。 ^^"
有一个问题,如果行数比较多,表格只能看到一部分数据,其他数据需要向左向右滚动才能看到。
在这个情况下,setItemDelegate的createEditor函数只会对当前可见的行生效,如果不滚动,那其他地方的单元格实际上并没有执行createEditor,也就是没有立即生成控件。但是在滚动的过程中它会马上创建,有什么办法可以在一开始就全部创建吗?
谢谢你~
to xhlove
抱歉,Heresy 這邊沒有碰到這樣的問題。
不過,理論上這邊是由 Qt 控制的,既然在拉過去之後會呼叫、產生,應該也不會有產生問題吧?
我遇到一个情况是有问题的,我在第一列通过Delegate返回一个CheckBox,另外有一个按钮作用是让每一行的CheckBox全部被选中或者被取消选中。
当我设置好表格内容的时候(假设有12行数据,最后两行需要向下滚动才能查看),最开始只有前10行实际生成了CheckBox,这个时候就只能对这10行的CheckBox进行选中/取消选中操作。
这样就无法达成全部行选中的目的了。
尝试通过scrollToBottom或者scrollToTop模拟滚动动作,但似乎不会触发createEditor被调用。
我只是想能够标记所有行,这样可以处理选中的行,但也可能一部分行,请问你有什么其他的建议吗?谢谢啦!
to xhlove
不要靠 Checkbox 來記錄狀態,而是要把資料儲存在 data model item 裡,這邊產生的 widget 是用來反映 data model item 的內容的。