Пользовательский интерфейс в Qt

Drag-and-Drop в Qt Widgets: перетаскивание элементов между виджетами

Введение

Перетаскивание элементов мышью между разными частями интерфейса (или даже между разными приложениями) — привычный паттерн взаимодействия, который пользователи интуитивно ожидают для списков, файлов, элементов канбан-доски. Qt Widgets поддерживает drag-and-drop через события QDragEnterEvent, QDropEvent и класс QMimeData для передачи произвольных данных в процессе перетаскивания. В статье реализуем перетаскивание элементов между двумя списками.

Концепция

Источник перетаскивания (виджет, с которого начинается drag) инициирует операцию через создание QDrag, заполняя его QMimeData нужными данными (текст, список URL, или произвольные кастомные данные с собственным MIME-типом) и вызывая exec(). Виджет-приёмник должен явно разрешить приём через setAcceptDrops(true) и обработать последовательность событий: dragEnterEvent (решить, принимать ли данный тип данных, вызвав event->acceptProposedAction()), опционально dragMoveEvent, и финальный dropEvent (фактически обработать сброшенные данные).

Пример кода: Drag-and-Drop в Qt Widgets

// draggablelistwidget.h
#pragma once
#include <QListWidget>
#include <QDrag>
#include <QMimeData>
#include <QMouseEvent>

class DraggableListWidget : public QListWidget
{
    Q_OBJECT
public:
    explicit DraggableListWidget(QWidget *parent = nullptr) : QListWidget(parent)
    {
        setAcceptDrops(true);
        setDragEnabled(true);
    }

protected:
    void dragEnterEvent(QDragEnterEvent *event) override
    {
        if (event->mimeData()->hasText()) {
            event->acceptProposedAction();
        }
    }

    void dragMoveEvent(QDragMoveEvent *event) override
    {
        if (event->mimeData()->hasText()) {
            event->acceptProposedAction();
        }
    }

    void dropEvent(QDropEvent *event) override
    {
        if (event->mimeData()->hasText()) {
            const QString text = event->mimeData()->text();
            addItem(text);

            // Если перетаскивание из другого виджета этого же типа,
            // удаляем элемент из источника при перемещении (а не копировании)
            if (event->source() != this) {
                if (auto *sourceList = qobject_cast<DraggableListWidget *>(event->source())) {
                    delete sourceList->currentItem();
                }
            }

            event->acceptProposedAction();
        }
    }

    void startDrag(Qt::DropActions supportedActions) override
    {
        QListWidgetItem *item = currentItem();
        if (!item) return;

        auto *mimeData = new QMimeData;
        mimeData->setText(item->text());

        auto *drag = new QDrag(this);
        drag->setMimeData(mimeData);
        drag->exec(supportedActions);
    }
};
// Использование: две связанные доски с задачами
#include <QHBoxLayout>
#include <QWidget>
#include "draggablelistwidget.h"

class TaskBoard : public QWidget
{
    Q_OBJECT
public:
    TaskBoard(QWidget *parent = nullptr) : QWidget(parent)
    {
        auto *todoList = new DraggableListWidget(this);
        todoList->addItems({"Изучить Qt", "Написать статью", "Провести код-ревью"});

        auto *doneList = new DraggableListWidget(this);

        auto *layout = new QHBoxLayout(this);
        layout->addWidget(todoList);
        layout->addWidget(doneList);
    }
};

Пояснения к коду

Переопределение startDrag() определяет, что происходит при начале перетаскивания элемента из списка: создаётся QMimeData с текстом текущего элемента, оборачивается в QDrag и запускается через exec() — этот вызов блокирует выполнение до завершения операции перетаскивания (отпускания кнопки мыши над валидной или невалидной целью).

В dropEvent обрабатывается сброс данных: новый элемент добавляется в список-приёмник, а если перетаскивание происходило из другого экземпляра DraggableListWidget (event->source() != this), исходный элемент удаляется из списка-источника — это реализует семантику «перемещения» элемента между списками, а не копирования. Если убрать эту проверку и удаление, поведение стало бы копированием вместо перемещения.

Подводные камни

  • Отсутствие проверки event->source() перед приведением типа через qobject_cast может привести к попытке вызвать методы на объекте неожиданного типа, если перетаскивание происходит из виджета другого класса, поддерживающего тот же MIME-тип — qobject_cast возвращает nullptr при несовпадении типа, что в примере корректно обрабатывается условием if (auto *sourceList = ...), но об этой проверке легко забыть.
  • Удаление currentItem() источника без проверки, что это действительно тот элемент, который был перетащен. Если пользователь успел изменить выделение в списке-источнике между началом перетаскивания и завершением сброса (что технически возможно в сложных сценариях с несколькими источниками ввода), currentItem() может вернуть не тот элемент — для надёжности стоит хранить явную ссылку на перетаскиваемый элемент в QMimeData (например, его индекс), а не полагаться на текущее выделение в момент dropEvent.
  • Перетаскивание между приложениями (а не только внутри одного) требует, чтобы MIME-тип данных был достаточно стандартным и предсказуемым для приложения-приёмника — кастомные MIME-типы данных хорошо работают для внутреннего drag-and-drop, но не гарантируют совместимость с другими приложениями.
  • Игнорирование Qt::DropActions (Copy/Move/Link) и визуальной индикации текущего действия. Без явной настройки поддерживаемых действий и соответствующего курсора пользователь не получает визуальной обратной связи о том, копируется элемент или перемещается — это особенно важно при перетаскивании с зажатыми модификаторами (Ctrl для копирования вместо перемещения, как принято в большинстве файловых менеджеров).