Qt:如何使用自定义模型实现简单的内部拖放以重新排序 QListView 中的项目

Qt: How to implement simple internal drag&drop for reordering items in QListView using a custom model

我有一个 QList 自定义结构,我正在使用自定义模型 class(QAbstractListModel 的子class)以一维方式显示这些结构列表视图。我已经覆盖了方法 rowCountflagsdata 以从结构元素构造显示字符串。

现在我想启用内部拖放功能,以便能够通过将它们拖放到其他一些项目之间来重新排序列表中的项目,但这项任务似乎复杂得令人难以置信。我究竟需要覆盖什么以及我需要设置哪些参数?我尝试了很多东西,我尝试了

view->setDragEnabled( true );
view->setAcceptDrops( true );
view->setDragDropMode( QAbstractItemView::InternalMove );
view->setDefaultDropAction( Qt::MoveAction );

我试过了

Qt::DropActions supportedDropActions() const override {
    return Qt::MoveAction;
}
Qt::ItemFlags flags( const QModelIndex & index ) const override{
    return QAbstractItemModel::flags( index ) | Qt::ItemIsDragEnabled;
}

我尝试实施 insertRowsremoveRows,但它仍然不起作用。

我还没有找到一个代码示例可以完全做到这一点。官方文档非常深入地介绍了 view/model 模式的工作原理以及如何从外部应用程序或其他小部件进行拖放,但我不想要这些。我只需要简单的内部拖放操作来手动重新排序该列表视图中的项目。

有人可以帮助我吗?不然我会发疯的。

编辑:根据要求添加insertRows/removeRows实施:

bool insertRows( int row, int count, const QModelIndex & parent ) override
{
    QAbstractListModel::beginInsertRows( parent, row, row + count - 1 );

    for (int i = 0; i < count; i++)
        AObjectListModel<Object>::objectList.insert( row, Object() );

    QAbstractListModel::endInsertRows();
    return true;
}

bool removeRows( int row, int count, const QModelIndex & parent ) override
{
    if (row < 0 || row + count > AObjectListModel<Object>::objectList.size())
        return false;

    QAbstractListModel::beginRemoveRows( parent, row, row + count - 1 );

    for (int i = 0; i < count; i++)
        AObjectListModel<Object>::objectList.removeAt( row );

    QAbstractListModel::endRemoveRows();
    return true;
}

objectList 是 QList,其中 Object 是模板参数。

当您想要重新组织自定义模型中的项目时,您必须执行所有需要的操作: - 如何插入和删除一行 - 如何获取和设置数据 - 如何序列化项目(构建 mimedata) - 如何反序列化项目

QStringList 作为数据源的自定义模型示例:

模型的最小实现应该是:

class CustomModel: public QAbstractListModel
{
public:
    CustomModel()
    {
        internalData = QString("abcdefghij").split("");
    }
    int rowCount(const QModelIndex &parent) const
    {
        return internalData.length();
    }
    QVariant data(const QModelIndex &index, int role) const
    {
        if (!index.isValid() || index.parent().isValid())
            return QVariant();
        if (role != Qt::DisplayRole)
            return QVariant();
        return internalData.at(index.row());
    }
private:
    QStringList internalData;   
};

我们必须将方法添加到 insert/remove 行并设置数据:

    bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::DisplayRole)
    {
        if (role != Qt::DisplayRole)
            return false;
        internalData[index.row()] = value.toString();
        return true;
    }
    bool insertRows(int row, int count, const QModelIndex &parent)
    {
        if (parent.isValid())
            return false;
        for (int i = 0; i != count; ++i)
            internalData.insert(row + i, "");
        return true;
    }
    bool removeRows(int row, int count, const QModelIndex &parent)
    {
        if (parent.isValid())
            return false;
        beginRemoveRows(parent, row, row + count - 1);
        for (int i = 0; i != count; ++i)
            internalData.removeAt(row);
        endRemoveRows();
        return true;
    }

对于拖放部分:

首先,我们需要定义一个 mime 类型来定义反序列化数据的方式:

    QStringList mimeTypes() const
    {
        QStringList types;
        types << CustomModel::MimeType;
        return types;
    }

其中 CustomModel::MimeType 是常量字符串,如 "application/my.custom.model"

方法canDropMimeData将用于检查丢弃的数据是否合法。所以,我们可以丢弃外部数据:

    bool canDropMimeData(const QMimeData *data,
        Qt::DropAction action, int /*row*/, int /*column*/, const QModelIndex& /*parent*/)
    {
        if ( action != Qt::MoveAction || !data->hasFormat(CustomModel::MimeType))
            return false;
        return true;
    }

然后,我们可以根据内部数据创建我们的mime数据:

    QMimeData* mimeData(const QModelIndexList &indexes) const
    {
        QMimeData* mimeData = new QMimeData;
        QByteArray encodedData;

        QDataStream stream(&encodedData, QIODevice::WriteOnly);

        for (const QModelIndex &index : indexes) {
            if (index.isValid()) {
                QString text = data(index, Qt::DisplayRole).toString();
                stream << text;
            }
        }
        mimeData->setData(CustomModel::MimeType, encodedData);
        return mimeData;
    }

现在,我们必须处理丢失的数据。我们必须反序列化 mime 数据,插入一个新行以在正确的位置设置数据(对于 Qt::MoveAction,旧行将自动删除。这就是我们必须实施 removeRows 的原因):

bool dropMimeData(const QMimeData *data,
        Qt::DropAction action, int row, int column, const QModelIndex &parent)
    {
        if (!canDropMimeData(data, action, row, column, parent))
            return false;

        if (action == Qt::IgnoreAction)
            return true;
        else if (action  != Qt::MoveAction)
            return false;

        QByteArray encodedData = data->data("application/my.custom.model");
        QDataStream stream(&encodedData, QIODevice::ReadOnly);
        QStringList newItems;
        int rows = 0;

        while (!stream.atEnd()) {
            QString text;
            stream >> text;
            newItems << text;
            ++rows;
        }

        insertRows(row, rows, QModelIndex());
        for (const QString &text : qAsConst(newItems))
        {
            QModelIndex idx = index(row, 0, QModelIndex());
            setData(idx, text);
            row++;
        }

        return true;
    }

如果您想了解有关 Qt 中拖放系统的更多信息,请查看 the documentation

除了 Romha 的出色回答之外,我还想补充一些关于它的工作原理和令人困惑的细节。

官方 documentation says QAbstractItemModel 具有 mimeTypesmimeDatadropMimeData 的默认实现,只要可以用于内部移动和复制操作当您正确实施 datasetDatainsertRowsremoveRows.

从某种角度来看,他们是对的。它确实可以在不覆盖 mimeDatadropMimeData 的情况下工作,但只有当您的基础数据结构仅包含单个字符串时,这些字符串才从 data 返回并在 setData 中作为 DisplayRole 接收。当你有一个包含多个元素的复合对象列表(就像我有的那样)时,只有其中一个用于 DisplayRole,例如

struct Elem {
    QString name;
    int i;
    bool b;
}

QVariant data( const QModelIndex & index, int role ) const override
{
    return objectList[ index.row() ].name;
}
bool setData( const QModelIndex & index, const QVariant & value, int role ) override
{
    objectList[ index.row() ].name = value.toString();
}

那么默认实现实际上会这样做

QVariant data = data( oldIndex, Qt::DisplayRole );
insertRows( newIndex, 1 )
setData( newIndex, data, Qt::DisplayRole )
removeRows( oldIndex, 1 )

因此只能正确移动名称并保持结构的其余部分不变。现在明白了,但系统太复杂了,我以前没有意识到。

因此需要自定义 mimeDatadropMimeData 来移动结构的全部内容

这里有一个为你证明的例子,但在 Python:

import sys
from PySide6 import QtCore, QtGui, QtWidgets
from PySide6.QtCore import (Qt, QStringListModel, QModelIndex,
                          QMimeData, QByteArray, QDataStream, QIODevice)
from PySide6.QtWidgets import (QApplication, QMainWindow, QListView, QAbstractItemView, QPushButton, QVBoxLayout, QWidget)


class DragDropListModel(QStringListModel):
    def __init__(self, parent=None):
        super(DragDropListModel, self).__init__(parent)
        # self.myMimeTypes = 'application/vnd.text.list' # 可行

        # self.myMimeTypes = "text/plain" # 可行
        self.myMimeTypes = 'application/json'  # 可行

    def supportedDropActions(self):
        # return Qt.CopyAction | Qt.MoveAction  # 拖动时复制并移动相关项目
        return Qt.MoveAction  # 拖动时移动相关项目

    def flags(self, index):
        defaultFlags = QStringListModel.flags(self, index)

        if index.isValid():
            return Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled | defaultFlags
        else:
            return Qt.ItemIsDropEnabled | defaultFlags

    def mimeTypes(self):
        return [self.myMimeTypes]

    # 直接将indexes里面对应的数据取出来,然后打包进了QMimeData()对象,并返回
    def mimeData(self, indexes):
        mmData = QMimeData()
        encodedData = QByteArray()
        stream = QDataStream(encodedData, QIODevice.WriteOnly)

        for index in indexes:
            if index.isValid():
                text = self.data(index, Qt.DisplayRole)
                stream << text  # 测试,也行
                # stream.writeQString(str(text))  # 原始, 可行

        mmData.setData(self.myMimeTypes, encodedData)
        return mmData

    def canDropMimeData(self, data, action, row, column, parent):
        if data.hasFormat(self.myMimeTypes) is False:
            return False
        if column > 0:
            return False
        return True

    def dropMimeData(self, data, action, row, column, parent):
        if self.canDropMimeData(data, action, row, column, parent) is False:
            return False

        if action == Qt.IgnoreAction:
            return True

        beginRow = -1
        if row != -1:  # 表示
            print("case 1: ROW IS NOT -1, meaning inserting in between, above or below an existing node")
            beginRow = row
        elif parent.isValid():
            print("case 2: PARENT IS VALID, inserting ONTO something since row was not -1, "
                  "beginRow becomes 0 because we want to "
                  "insert it at the beginning of this parents children")
            beginRow = parent.row()
        else:
            print("case 3: PARENT IS INVALID, inserting to root, "
                  "can change to 0 if you want it to appear at the top")
            beginRow = self.rowCount(QModelIndex())
        print(f"row={row}, beginRow={beginRow}")

        encodedData = data.data(self.myMimeTypes)
        stream = QDataStream(encodedData, QIODevice.ReadOnly)
        newItems = []
        rows = 0

        while stream.atEnd() is False:
            text = stream.readQString()
            newItems.append(str(text))
            rows += 1

        self.insertRows(beginRow, rows, QModelIndex())  # 先插入多行
        for text in newItems:  # 然后给每一行设置数值
            idx = self.index(beginRow, 0, QModelIndex())
            self.setData(idx, text)
            beginRow += 1

        return True


class DemoDragDrop(QWidget):
    def __init__(self, parent=None):
        super(DemoDragDrop, self).__init__(parent)

        # 设置窗口标题
        self.setWindowTitle('drag&drop in PySide6')
        # 设置窗口大小
        self.resize(480, 320)

        self.initUi()

    def initUi(self):
        self.vLayout = QVBoxLayout(self)
        self.listView = QListView(self)
        self.listView.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.listView.setDragEnabled(True)
        self.listView.setAcceptDrops(True)
        self.listView.setDropIndicatorShown(True)
        self.ddm = DragDropListModel()  # 该行和下面4行的效果类似
        # self.listView.setDragDropMode(QAbstractItemView.InternalMove)
        # self.listView.setDefaultDropAction(Qt.MoveAction)
        # self.listView.setDragDropOverwriteMode(False)
        # self.ddm = QStringListModel()

        self.ddm.setStringList(['Item 1', 'Item 2', 'Item 3', 'Item 4'])
        self.listView.setModel(self.ddm)

        self.printButton = QPushButton("Print")

        self.vLayout.addWidget(self.listView)
        self.vLayout.addWidget(self.printButton)

        self.printButton.clicked.connect(self.printModel)

    def printModel(self):  # 验证移动view中项目后,背后model中数据也发生了移动
        print(self.ddm.data(self.listView.currentIndex()))


if __name__ == '__main__':

    app = QApplication(sys.argv)
    app.setStyle('fusion')
    window = DemoDragDrop()
    window.show()
    sys.exit(app.exec_())