Every list in a Qt Quick application — the scene browser, a job queue, search
results — is a ListView (or TableView, Repeater, GridView) drawing a
model. The model is C++; the view and its delegates are QML. The two never
share data structures: the view asks the model questions (rowCount(),
data(index, role)) and the model announces changes through signals. Almost
every model bug — the sidebar that shows a stale row, the selection that jumps
to the neighbour, the crash right after a delete — is an announcement that
doesn't match the answers that follow it.
Interviews for Qt roles probe exactly this: "write a list model", "what's the
beginMoveRows destination when moving down?", "how do you test a model?",
"how do you expose it to QML?". This lesson builds on qt-object-lifetime
(QObject ownership, connections and their context objects) and teaches the
model contract, its tests, and the QML side.
1. Model, view, delegate, role
roleNames() is the dictionary that turns QML property names into role numbers.- A
QModelIndexnames one cell: row, column, parent (for trees) and the model it belongs to. It is a snapshot, valid until the model next changes.QModelIndex()(invalid) is the invisible root; the rows of a list are the root's children. - A role is an
intsaying which aspect of a cell you want:Qt::DisplayRole(the text),Qt::DecorationRole(an icon)… and your own fromQt::UserRole + 1up. QML sees roles by name, throughroleNames(). - A
QPersistentModelIndexis registered with the model, which updates it on every announced insert, remove and move — the view's selection and current item are persistent indexes.
For a flat list you subclass QAbstractListModel and implement three
virtuals. For trivial data Qt also offers QStringListModel,
QStandardItemModel and, since Qt 6.10, QRangeModel (wraps a C++ range
without subclassing); you write your own when rows come from your data
structures, change incrementally, or need custom roles for QML.
2. The read side: rowCount, data, roleNames
#include <QAbstractListModel>
#include <QList>
#include <print>
struct Scene { QString name; int splats; };
class Scenes : public QAbstractListModel {
public:
enum Role { NameRole = Qt::UserRole + 1, SplatCountRole };
int rowCount(const QModelIndex& parent = QModelIndex()) const override {
return parent.isValid() ? 0 : int(items_.size()); // list rows have no children
}
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override {
if (!checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid))
return {}; // invalid, foreign or stale
const Scene& s = items_.at(index.row());
switch (role) {
case Qt::DisplayRole:
case NameRole: return s.name;
case SplatCountRole: return s.splats;
default: return {};
}
}
QHash<int, QByteArray> roleNames() const override {
auto names = QAbstractListModel::roleNames(); // keep "display", "decoration", …
names.insert(NameRole, "name");
names.insert(SplatCountRole, "splatCount");
return names;
}
QList<Scene> items_{{"garden", 1200}, {"bicycle", 640}};
};
int main() {
Scenes m;
for (int r = 0; r < m.rowCount(); ++r)
std::println("{}: {} splats", m.data(m.index(r)).toString().toStdString(),
m.data(m.index(r), Scenes::SplatCountRole).toInt());
std::println("root has {} rows, row 0 has {} children", m.rowCount(), m.rowCount(m.index(0)));
std::println("data(invalid) valid? {}", m.data(QModelIndex()).isValid());
std::println("role {} is called '{}'", int(Scenes::SplatCountRole),
m.roleNames().value(Scenes::SplatCountRole).toStdString());
}The rules that matter:
rowCount(validParent)is 0. That's what makes a list a list; without it a tree view (andQAbstractItemModelTester) would think every row has children.data()never trusts its index. Views, proxies and QML hand you invalid indexes, indexes of another model (a proxy's), and stale ones taken before a removal.checkIndex(index, IndexIsValid | ParentIsInvalid)checks the model, the row/column range and the parent (and logs a warning — handy in a debugQ_ASSERT); a hand-written check must cover the same cases. Unknown roles returnQVariant().roleNames()starts from the base class. Building a freshQHashsilently deletesdisplay& co. from QML.- Return the natural type:
intfor counts,QStringfor text. For an enum-like status, either register the enum withQ_ENUM(needs moc) or — as this skill's task does — return a stable string like"ready"that QML compares directly.
No Q_OBJECT here: a model that only overrides virtuals and uses the signals
QAbstractItemModel already declares needs no moc. You need Q_OBJECT when
you add your own signals, slots, Q_PROPERTYs or QML registration (§7).
3. Changing rows: begin, mutate, end
Views keep state derived from the model: delegates for the visible rows, a selection, a scroll position, persistent indexes. To keep that state right, every structural change is bracketed:
| change | announce with | range |
|---|---|---|
| insert rows | beginInsertRows(parent, first, last) … endInsertRows() |
the new rows' indexes, inclusive |
| remove rows | beginRemoveRows(parent, first, last) … endRemoveRows() |
the doomed rows, inclusive |
| move rows | beginMoveRows(src, first, last, dst, destinationChild) … endMoveRows() |
see §4 |
| replace everything | beginResetModel() … endResetModel() |
— |
| values of existing rows | dataChanged(topLeft, bottomRight, roles) |
after the change |
The begin call comes before the mutation — it emits
rowsAboutToBeInserted/Removed/Moved, and connected views and proxies read the
model's old state at that moment. The end call emits rowsInserted/…
after it.
rowsAboutToBeInserted still sees 2 rows; handling rowsInserted, 5.The ranges are inclusive: inserting n rows at first is
(first, first + n − 1).
Here is the protocol observed from outside, the way a test (or a view) sees it:
#include <QAbstractListModel>
#include <QStringList>
#include <print>
class Names : public QAbstractListModel {
public:
int rowCount(const QModelIndex& p = {}) const override { return p.isValid() ? 0 : int(rows_.size()); }
QVariant data(const QModelIndex& i, int role) const override {
return checkIndex(i, CheckIndexOption::IndexIsValid) && role == Qt::DisplayRole ? QVariant(rows_.at(i.row()))
: QVariant();
}
void append(const QStringList& more) {
if (more.isEmpty()) return; // nothing to announce
const int first = int(rows_.size());
beginInsertRows(QModelIndex(), first, first + int(more.size()) - 1);
rows_ += more;
endInsertRows();
}
bool remove(int row, int count) {
if (row < 0 || count < 1 || row >= rowCount() || count > rowCount() - row) return false;
beginRemoveRows(QModelIndex(), row, row + count - 1);
rows_.remove(row, count);
endRemoveRows();
return true;
}
private:
QStringList rows_{"a", "b"};
};
int main() {
Names m;
auto show = [&](const char* what) {
return [&m, what](const QModelIndex&, int first, int last) {
std::println("{:<22} {}..{} rowCount={}", what, first, last, m.rowCount());
};
};
QObject::connect(&m, &QAbstractItemModel::rowsAboutToBeInserted, show("rowsAboutToBeInserted"));
QObject::connect(&m, &QAbstractItemModel::rowsInserted, show("rowsInserted"));
QObject::connect(&m, &QAbstractItemModel::rowsAboutToBeRemoved, show("rowsAboutToBeRemoved"));
QObject::connect(&m, &QAbstractItemModel::rowsRemoved, show("rowsRemoved"));
m.append({"c", "d", "e"});
m.remove(1, 2);
std::println("remove(1, INT_MAX) -> {}", m.remove(1, 2147483647));
}Two habits worth copying. Validate first, announce second: a rejected call
must emit nothing. And write range checks that can't overflow:
count > rowCount() - row, never row + count > rowCount().
4. Moves and the "move down" rule
beginMoveRows(sourceParent, first, last, destinationParent, destinationChild)
moves rows [first, last] so that they end up before row
destinationChild — where destinationChild counts rows of the list as it
is before the move. Moving up, that equals the final index. Moving down, it
doesn't: the moved row leaves a gap above the destination, so the destination
is the final index + 1.
Two consequences you will hit in practice:
- Destinations
firstandlast + 1mean "stay where you are".beginMoveRowsreturnsfalsefor them (and for destinations inside the moved range); if it returns false you must not callendMoveRows. So the buggydest = tomakes "move down by one" silently do nothing. - For longer downward moves the buggy destination doesn't fail — it makes Qt place persistent indexes one row above the data. The selection lands on the neighbour; only a persistent-index test catches it.
#include <QAbstractListModel>
#include <QPersistentModelIndex>
#include <QStringList>
#include <print>
class Names : public QAbstractListModel {
public:
int rowCount(const QModelIndex& p = {}) const override { return p.isValid() ? 0 : int(rows_.size()); }
QVariant data(const QModelIndex& i, int role) const override {
return checkIndex(i, CheckIndexOption::IndexIsValid) && role == Qt::DisplayRole ? QVariant(rows_.at(i.row()))
: QVariant();
}
// Move one row so that it ends at index `to` (like QList::move).
bool move(int from, int to, bool buggy = false) {
if (from < 0 || to < 0 || from >= rowCount() || to >= rowCount()) return false;
if (from == to) return true;
const int dest = (to > from && !buggy) ? to + 1 : to;
if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), dest)) return false;
rows_.move(from, to);
endMoveRows();
return true;
}
QString all() const { return rows_.join(""); }
private:
QStringList rows_{"a", "b", "c", "d", "e"};
};
int main() {
for (bool buggy : {false, true}) {
Names m;
QPersistentModelIndex selected(m.index(1)); // the view's selection: "b"
bool down1 = m.move(0, 1, buggy); // a ↔ b
std::println("{}: move(0,1) {} -> {}", buggy ? "dest = to " : "dest = to + 1", down1, m.all().toStdString());
m.move(1, 3, buggy); // whatever is at 1 goes to 3
std::println("{} list {} selection row {} shows '{}'", " ", m.all().toStdString(),
selected.row(), m.data(selected, Qt::DisplayRole).toString().toStdString());
}
}5. dataChanged, roles, and when to reset
When values of existing rows change, emit
dataChanged(topLeft, bottomRight, roles) after the change. The role list
says which bindings to refresh:
- The exact roles that changed — the goal. A QML delegate re-evaluates only the bindings that depend on those roles. A scene's splat count ticking up during loading shouldn't re-layout its name.
- Empty list — "any role may have changed". Correct, but every binding of the row re-runs; fine for rare updates, wasteful for progress counters.
- Too few roles — a real bug: the delegate's
errorMessagetext keeps its old value until scrolling recreates the delegate. - Nothing changed — emit nothing.
Compute the list by comparing old and new values field by field; a changed
name is both Qt::DisplayRole and NameRole if both map to it. Coalesce:
one dataChanged over a contiguous range beats one per row.
beginResetModel()/endResetModel() tells views to throw everything away:
delegates, selection, current item, scroll position; every persistent index
becomes invalid. It is right for "open a different project" and wrong for "one
scene finished loading". Forgetting the pair entirely — items_ = newList; —
is the classic silent bug: the view keeps showing old rows until something
forces it to re-query. (layoutAboutToBeChanged/layoutChanged exist for
re-sorting the same rows; proxies like QSortFilterProxyModel use them.)
6. Testing models
Three tools, all usable without a display.
QAbstractItemModelTester (QtTest) connects to every signal of a model
and checks the contract: rowCount/data agree with what was announced,
invalid indexes give invalid data, list rows have no children, ranges are
consistent. In Fatal mode the first violation aborts the process; Warning
logs; QtTest reports through QVERIFY inside a QtTest test function. Attach
one to every model in your test suite — it catches mistakes that unit tests
written by the same author miss.
QSignalSpy records each emission's arguments as a QList<QVariant>, so a
test can assert the exact range, destination and roles.
QPersistentModelIndex in a test is the strongest move/remove check: Qt
updates it from your announcements, so it exposes any mismatch with your data.
#include <QAbstractListModel>
#include <QSignalSpy>
#include <QStringList>
#include <QtTest/QAbstractItemModelTester>
#include <print>
class Names : public QAbstractListModel {
public:
enum { UpperRole = Qt::UserRole + 1 };
int rowCount(const QModelIndex& p = {}) const override { return p.isValid() ? 0 : int(rows_.size()); }
QVariant data(const QModelIndex& i, int role) const override {
if (!checkIndex(i, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)) return {};
if (role == Qt::DisplayRole) return rows_.at(i.row());
if (role == UpperRole) return rows_.at(i.row()).toUpper();
return {};
}
void rename(int row, const QString& name) {
if (row < 0 || row >= rowCount() || rows_[row] == name) return;
rows_[row] = name;
emit dataChanged(index(row), index(row), {Qt::DisplayRole, UpperRole});
}
void append(const QString& name) {
beginInsertRows(QModelIndex(), rowCount(), rowCount());
rows_ << name;
endInsertRows();
}
private:
QStringList rows_{"garden"};
};
int main() {
Names m;
QAbstractItemModelTester tester(&m, QAbstractItemModelTester::FailureReportingMode::Fatal);
QSignalSpy inserted(&m, &QAbstractItemModel::rowsInserted);
QSignalSpy changed(&m, &QAbstractItemModel::dataChanged);
m.append("bicycle");
m.rename(1, "truck");
m.rename(1, "truck"); // no change: no signal
std::println("rowsInserted ×{}: first={} last={}", inserted.count(), inserted.at(0).at(1).toInt(),
inserted.at(0).at(2).toInt());
auto roles = changed.at(0).at(2).value<QList<int>>();
std::println("dataChanged ×{}: row {} roles {} {}", changed.count(),
changed.at(0).at(0).value<QModelIndex>().row(), roles.at(0), roles.at(1));
std::println("the tester saw nothing wrong");
}For application tests you'll use QtTest proper — a QObject subclass whose
private slots are test functions, run by QTEST_MAIN (it needs moc, so it
lives in the project, not the sandbox):
class TestSceneListModel : public QObject {
Q_OBJECT
private slots:
void initTestCase() { qputenv("QT_QPA_PLATFORM", "offscreen"); } // or set it in CTest
void setStatus_data() { // data-driven: a table…
QTest::addColumn<int>("status");
QTest::addColumn<QString>("message");
QTest::addColumn<int>("emitted");
QTest::newRow("ready") << int(SceneStatus::Ready) << QString() << 1;
QTest::newRow("error") << int(SceneStatus::Error) << "bad header" << 1;
QTest::newRow("same") << int(SceneStatus::Loading) << QString() << 0;
}
void setStatus() { // …and a test per row
QFETCH(int, status);
QFETCH(QString, message);
QFETCH(int, emitted);
SceneListModel m;
QAbstractItemModelTester tester(&m, QAbstractItemModelTester::FailureReportingMode::QtTest);
m.append({SceneInfo{"garden"}});
QSignalSpy spy(&m, &QAbstractItemModel::dataChanged);
m.setStatus(0, SceneStatus(status), message);
QCOMPARE(spy.count(), emitted);
}
void loadsAsynchronously() {
SceneLoader loader;
QSignalSpy done(&loader, &SceneLoader::finished);
loader.load(QFINDTESTDATA("fixtures/garden.ply"));
QVERIFY(done.wait(5000)); // returns as soon as the signal arrives
QTRY_COMPARE(loader.model()->rowCount(), 1); // polls with event processing, 5 s cap
}
};
QTEST_MAIN(TestSceneListModel)
#include "tst_scenelistmodel.moc"Asynchronous results arrive through the event loop, so tests must run it
while waiting. QSignalSpy::wait(ms) and QTRY_COMPARE/QTRY_VERIFY do that
and return as soon as the condition holds; QTest::qWait(ms) processes events
for a fixed time and is for "let things settle", not for "wait for the result".
The same mechanism in miniature — a timer stands in for a loader:
#include <QCoreApplication>
#include <QSignalSpy>
#include <QStringListModel>
#include <QTimer>
#include <print>
int main() {
int argc = 0;
QCoreApplication app(argc, nullptr);
QStringListModel model;
QTimer::singleShot(0, &model, [&] { model.setStringList({"garden", "bicycle"}); }); // "async load"
QSignalSpy reset(&model, &QAbstractItemModel::modelReset);
std::println("before the event loop: rows={} resets={}", model.rowCount(), reset.count());
bool arrived = reset.wait(5000); // runs the event loop until modelReset (or 5 s)
std::println("after wait: arrived={} rows={} resets={}", arrived, model.rowCount(), reset.count());
}GUI-level tests (a QQuickView loading Main.qml, Qt Quick Test's TestCase
items run by qmltestrunner) need a platform plugin; on a CI machine without
a display run them with QT_QPA_PLATFORM=offscreen, set per test in
CMake: set_tests_properties(tst_viewer PROPERTIES ENVIRONMENT QT_QPA_PLATFORM=offscreen).
7. Exposing the model to QML
QML needs moc-generated metadata, so this part lives in the project. A Qt 6
CMake setup declares a QML module; types marked QML_ELEMENT in its
sources are registered automatically:
find_package(Qt6 6.8 REQUIRED COMPONENTS Quick)
qt_standard_project_setup(REQUIRES 6.8) # AUTOMOC, sensible defaults
qt_add_executable(viewer main.cpp)
qt_add_qml_module(viewer
URI Scene
QML_FILES Main.qml SceneDelegate.qml
SOURCES scenelistmodel.h scenelistmodel.cpp)
target_link_libraries(viewer PRIVATE Qt6::Quick splat::core)class SceneListModel : public QAbstractListModel {
Q_OBJECT
QML_ELEMENT // usable as `SceneListModel {}` after `import Scene`
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
explicit SceneListModel(QObject* parent = nullptr) : QAbstractListModel(parent) {
connect(this, &QAbstractItemModel::rowsInserted, this, &SceneListModel::countChanged);
connect(this, &QAbstractItemModel::rowsRemoved, this, &SceneListModel::countChanged);
connect(this, &QAbstractItemModel::modelReset, this, &SceneListModel::countChanged);
}
int count() const { return rowCount(); }
Q_INVOKABLE bool remove(int row, int count = 1); // callable from QML
signals:
void countChanged();
// rowCount / data / roleNames / … as in §2–§5
};Q_PROPERTY(… NOTIFY sig): QML bindings re-evaluate only when the NOTIFY signal fires. A property without one is read once — andqmllintwarns. Emit it for every change that alters the value, and only then.QML_ELEMENT(+qt_add_qml_module) replaces the olderqmlRegisterType<SceneListModel>("Scene", 1, 0, "SceneListModel")call inmain(). For one shared instance useQML_SINGLETON, or pass it withQQmlApplicationEngine::setInitialProperties;setContextPropertystill works but is invisible to tooling and discouraged.Q_INVOKABLEmethods and slots are callable from QML; keep them thin and forward to the C++ API you test.
The view side, with required properties in the delegate:
import QtQuick
import QtQuick.Controls
import Scene
ListView {
id: list
model: SceneListModel { id: scenes }
delegate: ItemDelegate {
required property int index
required property string name
required property string status
required property string errorMessage
width: ListView.view.width
text: status === "error" ? name + " — " + errorMessage : name
highlighted: ListView.isCurrentItem
onClicked: list.currentIndex = index
}
footer: Label { text: scenes.count + " scenes" }
}A delegate that declares required properties gets them initialised from the
roles with the same names (plus index, model, modelData from the view),
and stops receiving the other roles as implicit context properties — so
the role names in roleNames() are now a typed interface between C++ and QML.
When the model emits dataChanged with StatusRole, the view updates
status and the text binding re-runs.
Bindings and binding loops
text: status === "error" ? … is a binding: QML records which properties
it read and re-evaluates it when any of them notifies. Two consequences:
- Assigning to a bound property from JavaScript (
width = 200in a handler) removes the binding silently. UseQt.binding(() => …)to install a new one, or restructure with states. - A cycle —
width: label.width + 16on the parent while the label haswidth: parent.width - 16— makes QML logBinding loop detected for property "width"and settle on an evaluation-order-dependent value. Size one from the other, never both ways; implicit sizes (implicitWidth) are the usual one-way channel upwards.
Loading and error states
Anything that loads — a scene file, a thumbnail, a QML component — has three states, and the UI needs one for each:
status role, Image.status and Loader.status all follow this shape. Each state needs a UI: a busy indicator, the content, an error with a retry.Image {
asynchronous: true
source: "image://thumbs/" + name // served by a QQuickAsyncImageProvider
BusyIndicator { anchors.centerIn: parent; running: parent.status === Image.Loading }
Label { visible: parent.status === Image.Error; text: qsTr("no preview") }
}
Loader {
id: details
asynchronous: true // compile/instantiate off the critical path
source: "SceneDetails.qml"
onStatusChanged: if (status === Loader.Error) console.warn("details failed to load")
}An async image provider keeps decoding off the GUI thread:
QQuickAsyncImageProvider::requestImageResponse(id, requestedSize) returns a
QQuickImageResponse* that does its work elsewhere (a thread pool) and emits
finished(); on failure its errorString() is non-empty and the Image
goes to Image.Error instead of showing a blank tile. Register it with
engine.addImageProvider("thumbs", new ThumbnailProvider) (the engine takes
ownership). Threading rules for the work itself are the subject of
qt-background-work.
8. Shipping it
A Qt Quick app needs the Qt libraries, the platform plugin, and every QML module it imports at run time. Qt 6's CMake API generates the deployment step:
qt_generate_deploy_qml_app_script(
TARGET viewer
OUTPUT_SCRIPT deploy_script)
install(TARGETS viewer)
install(SCRIPT ${deploy_script}) # runs at `cmake --install`It scans the app's QML imports and installs what they need next to the binary. Outside CMake the same job is done by windeployqt (Windows), macdeployqt (macOS) and, for Linux AppImages, linuxdeploy with its Qt plugin. Test the result on a clean machine or container: "module QtQuick.Controls is not installed" on someone else's machine means the deployment, not the code, is broken.
Summary
- A model answers (
rowCount,data,roleNames) and announces (begin…/end…,dataChanged, reset). Views trust the announcements. rowCount(validParent) == 0;data()rejects invalid, foreign, stale and out-of-range indexes;roleNames()extends the base class's names.- begin → mutate → end, with inclusive
[first, last]ranges; validate before announcing; no overflow in range checks. - Moving down:
destinationChild = to + 1.beginMoveRowsreturns false for no-op moves. dataChangedlists exactly the changed roles; reset is the sledgehammer.- Test every model with
QAbstractItemModelTester,QSignalSpyand persistent indexes; wait for signals, not for time; run GUI tests withQT_QPA_PLATFORM=offscreen. - QML:
QML_ELEMENT+qt_add_qml_module,Q_PROPERTYwithNOTIFY, required properties in delegates, no binding cycles, explicit loading/ready/error states; deploy withqt_generate_deploy_qml_app_script.
In the project
Ungraded practice in projects/scene-viewer/app/ (stage 2); the evidence is
your own build and ctest --preset app output.
- Compare
app/scene_list_model.cppwith your gradedSceneListModel: which signals does each operation emit, with which roles? - Add
removeandmove(the sidebar could reorder scenes) with exactly the right begin/end calls, and extendapp/tests/tst_scene_list_model.cpp— it already runsQAbstractItemModelTesterin QtTest mode. - In
app/qml/Main.qml, the delegate uses required properties and shows loading / error / ready states. Add a retry button for the error state that calls a newQ_INVOKABLEon the controller. - Run the app's tests headless (the
apptest preset setsQT_QPA_PLATFORM=offscreen) and under ASan. Take a screenshot withSPLAT_SCREENSHOT=out.pngontst_qml_smoke.