Freeze Columns in TableView
I recently needed to implement a TableView with frozen column support. Qt provides a good implementation of a table with good performance and item reusing, but it does not internally support headers or frozen columns.
In order to add a header do your TableView
, you can use
HorizontalHeaderView.
This will call QAbstractTableModel::headerData
internally when QAbstractTableModel::data
is
called with a role. This works great, but still, it does not give you the ability to freeze certain
columns. It will use all the cells that you return from QAbstractTableModel::headerData
and then
display them in a separate TableView
. It internally uses a specialized version of a proxy model
called QHeaderDataProxyModel
to do this. As a result, you end up with two table views on top of
each other, one that displays the data and one that displays the headers.
Since we want to freeze headers, we are going to have our own implementation.
When we are done with our implementation, the use of our FrozenTableView
will be as follows:
FrozenTableView {
frozenColumn: 3
model: TableModel { }
horizontalHeaderDelegate: TableViewHeaderCell {
required property int index
required property string display
text: display
cellIndex: index
}
delegate: TableViewCell {
required property int index
required property string display
text: display
}
}
We need to ability to freeze up to n
number of columns. And once we freeze a column, we not only
need the headers to freeze, but also the data that corresponds to those headers to also freeze.
However, if you look at the documentation for
TableView,
you will see that it inherits from Flickable
and it does not support flicking certain areas of
the table while the rest stays static.
Therefore, we need 4 tables:
1- One that shows the frozen header columns.
2- One that shows the un-frozen header columns.
3- One that shows the frozen data columns.
4- One that shows the un-frozen data columns.
Just because we need to divide the representation of our model into 4, it does not mean that we also need to divide our data to 4 so that we can support freezing columns. That would be crazy. What we need instead is a proxy model that operates on the source model and transforms it to
Let’s start with the fundamentals. We need a table model that we can apply proxies to.
// TableModel.cpp
namespace {
constexpr std::array<char, 26> s_characters{ 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N',
'O', 'P', 'Q', 'R', 'S', 'T', 'U',
'V', 'W', 'X', 'Y', 'Z' };
}
TableModel::TableModel(QObject* parent)
: QAbstractTableModel{ parent }
{
}
Qt::ItemFlags TableModel::flags(const QModelIndex& index) const
{
Q_UNUSED(index);
return Qt::ItemIsSelectable | Qt::ItemIsUserCheckable | Qt::ItemIsEnabled;
}
QHash<int, QByteArray> TableModel::roleNames() const
{
return { { Qt::DisplayRole, "display" } };
}
int TableModel::rowCount(const QModelIndex& /*index*/) const
{
return m_rowCount;
}
int TableModel::columnCount(const QModelIndex& /*index*/) const
{
return m_columnCount;
}
QVariant TableModel::data(const QModelIndex& index, int role) const
{
switch (role) {
case Qt::DisplayRole:
return QString{ "%1, %2" }.arg(index.column()).arg(index.row());
default:
break;
}
return {};
}
QVariant TableModel::headerData(int section,
Qt::Orientation orientation,
int role) const
{
Q_UNUSED(orientation);
switch (role) {
case Qt::DisplayRole:
return QString{ s_characters.at(section) };
default:
break;
}
return {};
}
QModelIndex TableModel::index(int row,
int column,
const QModelIndex& parent) const
{
// NOTE: We are using the first item in our data as the header row.
// That's why we need to add one to check for valid index.
if (row < rowCount(parent) + 1 && column < columnCount(parent)) {
return createIndex(row, column);
}
return QModelIndex{};
}
Once we have a working table model, we are going to create our proxy model so we can return
different parts of our model depending on our use case. We are going to call this proxy
ModelSlice
. We want to use this to tell the proxy that we are interested in getting data in a
certain row/column range rather than everything. We will use this to customize our source model for
our frozen/un-frozen header columns.
Here’s a slimmed down version of the interface of ModelSlice
:
class ModelSlice : public QAbstractListModel {
Q_OBJECT
Q_PROPERTY(int fromRow READ fromRow WRITE setFromRow NOTIFY fromRowChanged)
Q_PROPERTY(int toRow READ toRow WRITE setToRow NOTIFY toRowChanged)
Q_PROPERTY(int fromColumn READ fromColumn WRITE setFromColumn NOTIFY
fromColumnChanged)
Q_PROPERTY(
int toColumn READ toColumn WRITE setToColumn NOTIFY toColumnChanged)
// We are going to be operating on the source. This model will not hold onto any data but
// returns a slice of the data from our source.
Q_PROPERTY(QAbstractItemModel* source READ source WRITE setSource NOTIFY
sourceChanged)
public:
explicit ModelSlice(QObject* parent = nullptr);
// We will return the same role names without altering them.
[[nodiscard]] QHash<int, QByteArray> roleNames() const override final;
// Row and column count will depend on the specified row/column range.
[[nodiscard]] int rowCount(
const QModelIndex& parent = QModelIndex{}) const override;
[[nodiscard]] int columnCount(
const QModelIndex& parent = QModelIndex{}) const override;
// This will return our data in the specified row/column range.
[[nodiscard]] QVariant data(const QModelIndex& index,
int role) const override;
// In order to make this a general purpose slice, we need to implement this as well.
[[nodiscard]] QVariant headerData(
int section,
Qt::Orientation orientation,
int role = Qt::DisplayRole) const override final;
// Getters and setters for the Q_PROPERTY declarations above...
signals:
// Signals for our properties.
private:
// Member variables.
};
We can now use this implementation to get a certain slice of a source model.
// Get me the first three rows and the first tow columns from the source model.
ModelSlice {
source: root.model
fromRow: 0
toRow: 2
fromColumn: 0
toColumn: 1
}
Just as a side note, this could have been achieved with a few function calls to our table model as well. But when dealing with QML, it’s very important to make things as declarative as possible.
This ModelSlice
only returns the main data though, we need access to the header data. So, let’s
create another class that inherits from ModelSlice
called HeaderModelSlice
.
class HeaderModelSlice : public ModelSlice {
Q_OBJECT
// This is so that we can support both vertical and horizontal headers,
// however we will only be returning horizontal headers for simplicity.
Q_PROPERTY(Qt::Orientation orientation READ orientation WRITE setOrientation
NOTIFY orientationChanged)
// This part is important. Normally, we would auto adjust the row/column
// range to return only the first row as a header. But in order to freeze
// header columns, we need to adjust which columns are returned as well.
// Keeping this here for convenience in case the header is used when there's no need for
// freezing columns in other places.
Q_PROPERTY(bool useExplicitRange READ useExplicitRange WRITE
setUseExplicitRange NOTIFY useExplicitRangeChanged)
public:
explicit HeaderModelSlice(QObject* parent = nullptr);
// This will actually internally call source->headerData so that we can
// treat this proxy as a gateway to the underlying header.
[[nodiscard]] QVariant data(const QModelIndex& index,
int role) const override final;
// We have to specialize row/column count here because depending on the
// orientation, one of them will only be one, e.g for a horizontal header we
// return a row count of 0.
[[nodiscard]] int rowCount(
const QModelIndex& parent = QModelIndex{}) const override final;
[[nodiscard]] int columnCount(
const QModelIndex& parent = QModelIndex{}) const override final;
// Getters and setters for the properties are omitted here...
signals:
// Property signals.
};
Now that we are done with the C++ side, we can start getting into the QML land. Remember that we established we need 4 tables to accomplish what we want to do. Once we are done, we are going to put all these tables in a layout in such a way that the user can’t tell that we are actually using 4 different table views.
Let’s go over them one by one.
1. One that Shows the Frozen Header Columns
This table will be optionally visible. If we don’t have any frozen columns, we don’t need to show this table. Its job is to take a slice out of the header model and only show those cells. This is a 1xn type of column. We’ll always have a single row since its the header, and the column will be the number of frozen column headers we want.
This column is frozen, we only show cell "A" in this table.
|___|___|
⬇ ↔ ⬇
+-------+-------+-------+
| A | B | C |
+-------+-------+-------+
| A:1 | B:1 | B:1 |
+-------+-------+-------+
TableView {
id: columnHeader
boundsBehavior: Flickable.StopAtBounds
// We don't want interactivity here since it is frozen. Alternatively, you can enable this and
// add scroll bars.
interactive: false
model: HeaderModelSlice {
// This is our TableModel.
source: root.model
useExplicitRange: true
fromRow: 0
toRow: 0
fromColumn: 0
// Alternatively, we can disable column freezing, that's why we are setting the minimum
// value to 0 here. We need the cells that go from 0 to the given frozenColumn.
toColumn: Math.max(root.frozenColumn - 1, 0)
orientation: Qt.Horizontal
}
function _updateColumnWidth(index: int, width: int) {
// Skipping this for simplicity. Take a look at the GitHub repository for details.
}
function _updateHorizontalHeaderHeight(height: int) {
// Skipping this for simplicity. Take a look at the GitHub repository for details.
}
}
2. One that Shows the Un-frozen Header Columns
This table starts showing the columns starting with the column after our frozen column.
This is where we start showing in this table.
|_______|_______|
⬇ ↔ ⬇
+-------+-------+-------+
| A | B | C |
+-------+-------+-------+
| A:1 | B:1 | B:1 |
+-------+-------+-------+
TableView {
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: HeaderModelSlice {
source: root.model
useExplicitRange: true
fromRow: 0
toRow: 0
// Our header data starts from the column that we end the freezing and goes to the end of
// the model.
fromColumn: Math.max(root.frozenColumn, 0)
toColumn: root.model.columnCount
orientation: Qt.Horizontal
}
function _updateColumnWidth(index: int, width: int) {
// Skipping this for simplicity. Take a look at the GitHub repository for details.
}
function _updateHorizontalHeaderHeight(height: int) {
// Skipping this for simplicity. Take a look at the GitHub repository for details.
}
}
3. One that Shows the Frozen Data Columns
+-------+-------+-------+
| A | B | C |
+-→ +-------+-------+-------+
| | A:1 | B:1 | B:1 |
| +-------+-------+-------+
| | A:2 | B:2 | B:2 |
+-→ +-------+-------+-------+
⬆ ↔ ⬆
|___|___|
We show this column and the rest of the rows in this table.
TableView {
id: frozenColumnTable
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: HeaderModelSlice {
id: frozenColumnModel
source: root.model
useExplicitRange: true
fromRow: 0
toRow: 0
fromColumn: 0
toColumn: Math.max(root.frozenColumn - 1, 0)
orientation: Qt.Horizontal
}
// We need to duplicate these functions because a header cell that belongs to a
// frozen column will have a different table view than the one that's not frozen.
function _updateColumnWidth(index: int, width: int) {
// Skipping this for simplicity. Take a look at the GitHub repository for details.
}
function _updateHorizontalHeaderHeight(height: int) {
// Skipping this for simplicity. Take a look at the GitHub repository for details.
}
}
4. One that Shows the Un-frozen Data Columns.
This table will show the data that does not belong to our frozen column.
+-------+-------+-------+
| A | B | C |
+-------+-------+-------+ ←-+
| A:1 | B:1 | B:1 | |
+-------+-------+-------+ |
| A:2 | B:2 | B:2 | |
+-------+-------+-------+ ←-+
⬆ ↔ ⬆
|_______|_______|
We show these two cells in this table.
TableView {
id: tb
syncView: columnHeader.visible ? columnHeader : null
syncDirection: Qt.Horizontal
boundsBehavior: Flickable.StopAtBounds
clip: true
model: ModelSlice {
// We are not using a HeaderModelSlice here because we are no longer interested in
// ::headerData.
source: root.model
fromRow: 0
toRow: root.model.rowCount
fromColumn: frozenColumnModel.toColumn
toColumn: root.model.columnCount
}
}
Putting It All Together
Now that we have all our 4 tables, time to put them together. We are going to use layouts to cleverly position them so there’s no gap between them and they don’t look distinct but the same table all together.
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Table 1.0 // This is where our TableModel and ModelSlice types are.
Control {
id: root
property TableModel model
property alias delegate: tb.delegate
// We are going to use the same delegate for our frozen and unfrozen column headers.
property alias horizontalHeaderDelegate: columnHeader.delegate
property alias columnSpacing: columnHeader.columnSpacing
property alias rowSpacing: columnHeader.rowSpacing
// NOTE: This is used to ensure that the frozen column table is wide enough to contain all the
// frozen columns so we can properly calculate cell widths.
property int defaultCellWidth: 100
property int frozenColumn: -1
// NOTE: ColumnLayout does not set implicit size so by default this will evaluate to 0 if
// there's no padding.
implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding)
implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding)
contentItem: ColumnLayout {
spacing: 0
RowLayout {
height: columnHeader.height
spacing: 0
Layout.fillWidth: true
TableView {
id: frozenColumnTable
width: privates.frozenCellsCreated ? privates.frozenColumnWidth : root.defaultCellWidth * Math.max(root.frozenColumn - 1, 0)
boundsBehavior: Flickable.StopAtBounds
columnSpacing: columnHeader.columnSpacing
rowSpacing: columnHeader.rowSpacing
interactive: false
columnWidthProvider: (column) => privates.columnWidths[column]
model: HeaderModelSlice {
id: frozenColumnModel
source: root.model
useExplicitRange: true
fromRow: 0
toRow: 0
fromColumn: 0
toColumn: Math.max(root.frozenColumn - 1, 0)
orientation: Qt.Horizontal
}
delegate: root.horizontalHeaderDelegate
Layout.preferredWidth: width
Layout.preferredHeight: height
// We need to duplicate these functions because a header cell that belongs to a
// frozen column will have a different table view than the one that's not frozen.
function _updateColumnWidth(index: int, width: int) {
privates.columnWidths[index] = width
if (!privates.frozenCellsCreated) {
privates.frozenCellsCreated = index == frozenColumnModel.toColumn
}
privates.frozenColumnWidth = privates.calculateFrozenColumnTableWidth()
Qt.callLater(frozenRows.forceLayout)
Qt.callLater(frozenColumnTable.forceLayout)
}
function _updateHorizontalHeaderHeight(height: int) {
frozenColumnTable.height = height
columnHeader.height = height
}
}
TableView {
id: columnHeader
boundsBehavior: Flickable.StopAtBounds
interactive: false
clip: true
columnWidthProvider: (column) => privates.columnWidths[root.frozenColumn > 0 ? column + root.frozenColumn : column]
model: HeaderModelSlice {
source: root.model
useExplicitRange: true
fromRow: 0
toRow: 0
fromColumn: Math.max(root.frozenColumn, 0)
toColumn: root.model.columnCount
orientation: Qt.Horizontal
}
Layout.preferredHeight: height
Layout.fillWidth: true
function _updateColumnWidth(index: int, width: int) {
const columnIndex = root.frozenColumn > 0 ? index + root.frozenColumn : index
privates.columnWidths[columnIndex] = width
Qt.callLater(tb.forceLayout)
Qt.callLater(columnHeader.forceLayout)
}
function _updateHorizontalHeaderHeight(height: int) {
columnHeader.height = height
}
}
}
RowLayout {
spacing: 0
Layout.fillHeight: true
Layout.fillWidth: true
TableView {
id: frozenRows
width: frozenColumnTable.width
boundsBehavior: Flickable.StopAtBounds
columnSpacing: columnHeader.columnSpacing
rowSpacing: columnHeader.rowSpacing
contentY: tb.contentY
clip: true
syncView: tb
syncDirection: Qt.Vertical
delegate: tb.delegate
columnWidthProvider: (column) => privates.columnWidths[column]
model: ModelSlice {
source: root.model
fromRow: 0
toRow: root.model.rowCount
fromColumn: 0
toColumn: frozenColumnModel.toColumn
}
Layout.preferredWidth: width
Layout.fillHeight: true
}
TableView {
id: tb
columnSpacing: columnHeader.columnSpacing
rowSpacing: columnHeader.rowSpacing
syncView: columnHeader.visible ? columnHeader : null
syncDirection: Qt.Horizontal
boundsBehavior: Flickable.StopAtBounds
clip: true
columnWidthProvider: (column) => privates.columnWidths[root.frozenColumn > 0 ? column + root.frozenColumn : column]
model: ModelSlice {
source: root.model
fromRow: 0
toRow: root.model.rowCount
fromColumn: frozenColumnModel.toColumn
toColumn: root.model.columnCount
}
Layout.fillWidth: true
Layout.fillHeight: true
ScrollBar.vertical: ScrollBar { }
ScrollBar.horizontal: ScrollBar { }
}
}
}
QtObject {
id: privates
property var columnWidths: ({})
property int frozenColumnWidth: 0
property bool frozenCellsCreated: false
function calculateFrozenColumnTableWidth() {
let column = frozenColumnModel.toColumn
let width = 0
while (column > -1) {
const value = privates.columnWidths[column]
if (value !== undefined) {
width += value
}
column--
}
return width
}
}
}
In order to get the full experience, checkout the full source code at GitHub.