在 QTableView 內使用 QItemDelegate 來畫按鈕

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 這邊也只是先求能動,所以就沒做到這麼細了。 ^^"

4 thoughts on “在 QTableView 內使用 QItemDelegate 來畫按鈕”

  1. 有一个问题,如果行数比较多,表格只能看到一部分数据,其他数据需要向左向右滚动才能看到。
    在这个情况下,setItemDelegate的createEditor函数只会对当前可见的行生效,如果不滚动,那其他地方的单元格实际上并没有执行createEditor,也就是没有立即生成控件。但是在滚动的过程中它会马上创建,有什么办法可以在一开始就全部创建吗?
    谢谢你~

  2. to xhlove
    抱歉,Heresy 這邊沒有碰到這樣的問題。
    不過,理論上這邊是由 Qt 控制的,既然在拉過去之後會呼叫、產生,應該也不會有產生問題吧?

  3. 我遇到一个情况是有问题的,我在第一列通过Delegate返回一个CheckBox,另外有一个按钮作用是让每一行的CheckBox全部被选中或者被取消选中。
    当我设置好表格内容的时候(假设有12行数据,最后两行需要向下滚动才能查看),最开始只有前10行实际生成了CheckBox,这个时候就只能对这10行的CheckBox进行选中/取消选中操作。
    这样就无法达成全部行选中的目的了。
    尝试通过scrollToBottom或者scrollToTop模拟滚动动作,但似乎不会触发createEditor被调用。
    我只是想能够标记所有行,这样可以处理选中的行,但也可能一部分行,请问你有什么其他的建议吗?谢谢啦!

  4. to xhlove
    不要靠 Checkbox 來記錄狀態,而是要把資料儲存在 data model item 裡,這邊產生的 widget 是用來反映 data model item 的內容的。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *