Чтобы синтетическая карточка при нативном перетаскивании в macOS выглядела целой, назначьте её внешнему контейнеру один источник перетаскивания и отобразите всю карточку в качестве предпросмотра. Используйте AppKit, если вам нужно явно управлять начальным кадром предпросмотра и смещением указателя.

В этом руководстве рассматривается перетаскивание одной карточки, хранящейся в памяти, между двумя панелями вашего приложения для Mac. Практическая цель — сохранить формы, текст, интервалы и идентичность карточки, оставив обработку жестов и обновление модели под чётко определённой ответственностью.

Какую часть перетаскивания нужно изменить?

Apple разделяет передаваемые данные и визуальное представление, отображаемое во время перетаскивания. SwiftUI draggable(_:preview:) создаёт изображение вашего представления предпросмотра в момент начала перетаскивания. Согласно документации, предпросмотр располагается по центру над исходным представлением. Само исходное представление не передаётся. См. документацию Apple по draggable(_:preview:).

Воспользуйтесь этой таблицей, чтобы определить, какое именно управление вам нужно:

Желаемое поведение Рекомендуемый отправной вариант
Сохранить вместе подпись, фигуры и фон карточки Один источник перетаскивания на внешнем контейнере и один полный пользовательский предпросмотр
Настроить контур во время подъёма contentShape(.dragPreview, shape)
Изменить область карточки, откуда можно начать перетаскивание contentShape(.interaction, shape)
Переместить карточку в другую панель Проверить данные и обновить модель в месте назначения
Явно задать начальный прямоугольник изображения перетаскивания и его положение относительно указателя Сеанс под управлением AppKit с использованием NSDraggingItem
Непрерывно перемещать элемент по холсту без нативных мест назначения для сброса Локальное взаимодействие с DragGesture

Различие между двумя формами содержимого важно: Apple указывает, что .dragPreview влияет на форму предпросмотра, а .interaction управляет проверкой попадания. Одно лишь изменение контура предпросмотра не увеличивает область, в которой можно начать перетаскивание. См. ContentShapeKinds.dragPreview.

Локальный DragGesture сообщает о движении перетаскивания в последовательности жестов. Выберите этот подход, если задача заключается в обновлении положения на холсте. Для описанного в статье нативного сброса между панелями позвольте draggable установить жесты перетаскивания. См. справочник Apple по DragGesture и руководство по источникам перетаскивания.

Создайте один предпросмотр для всей синтетической карточки

Добавьте модификатор перетаскивания к самому маленькому контейнеру, который включает всё, что должно перемещаться вместе. Для карточки из круга, двух строк текста и скруглённого фона таким контейнером будет вся карточка. Если назначить отдельные источники перетаскивания дочерним элементам, это будут разные взаимодействия.

Повторно используйте одно представление для отображения исходной карточки и её предпросмотра. Задайте этому представлению явные размеры и собственный фон, не полагаясь на стили, унаследованные от окружающей панели. Это рекомендация по проектированию, основанная на поведении отображаемого предпросмотра: самостоятельное представление упрощает согласование двух вариантов внешнего вида.

Полный пример на SwiftUI

Создайте приложение macOS на SwiftUI с целевой версией macOS 13 или новее. Замените сгенерированные точку входа приложения и представление содержимого этим единственным файлом, оставив в целевом объекте ровно одно объявление @main. Используемые здесь модификатор пользовательского предпросмотра и перегрузка для места назначения, согласно документации, доступны начиная с macOS 13. См. draggable(_:preview:) и dropDestination(for:action:isTargeted:).

import Foundation
import SwiftUI

@main
struct IntactDragDemoApp: App {
    var body: some Scene {
        WindowGroup {
            BoardView()
        }
    }
}

private struct CardFace: View {
    var body: some View {
        HStack(spacing: 14) {
            Circle()
                .fill(Color.orange)
                .frame(width: 36, height: 36)

            VStack(alignment: .leading, spacing: 6) {
                Text("Tile A")
                    .font(.headline)
                Text("Circle and label")
                    .font(.caption)
            }
            .foregroundStyle(Color.white)
        }
        .padding(16)
        .frame(width: 220, height: 100, alignment: .leading)
        .background(Color.indigo)
        .clipShape(RoundedRectangle(cornerRadius: 16))
    }
}

private struct BoardView: View {
    @State private var token = UUID().uuidString
    @State private var isOnRight = false

    var body: some View {
        VStack(spacing: 20) {
            Text("Drag the card between panels")
                .font(.headline)

            HStack(spacing: 24) {
                panel(right: false)
                panel(right: true)
            }
        }
        .padding(24)
    }

    private func panel(right: Bool) -> some View {
        VStack(spacing: 16) {
            Text(right ? "Right" : "Left")
                .font(.headline)

            ZStack {
                if isOnRight == right {
                    CardFace()
                        .contentShape(
                            .interaction,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .contentShape(
                            .dragPreview,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .draggable(token) {
                            CardFace()
                        }
                } else {
                    Text("Drop here")
                        .foregroundStyle(.secondary)
                }
            }
            .frame(width: 240, height: 120)
        }
        .frame(width: 260, height: 180)
        .background(
            RoundedRectangle(cornerRadius: 20)
                .fill(Color.gray.opacity(0.12))
        )
        .contentShape(Rectangle())
        .dropDestination(for: String.self) { items, _ in
            guard items == [token] else {
                return false
            }
            isOnRight = right
            return true
        }
    }
}

В этом примере используются только синтетическая графика и сгенерированный идентификатор, хранящийся в памяти. Тип String уже соответствует протоколу Transferable, поэтому для него не нужно создавать собственное представление передаваемых данных. Apple описывает эту встроенную совместимость в разделе Создание источника перетаскивания из представления.

Ожидаемое взаимодействие простое: перетащите Tile A в другую панель, отпустите её, а затем перетащите обратно. Движущийся предпросмотр представляет собой целое представление CardFace. Место назначения принимает ровно один соответствующий токен и изменяет единственное значение местоположения. Возврат true означает успешный сброс; возврат false отклоняет его в соответствии с документированным контрактом обработчика действия для места назначения.

Это законченный пример для целевого объекта приложения macOS. Здесь он не компилировался и не запускался, поскольку в исследовательской среде нет ни инструментов Swift, ни среды выполнения macOS.

Разделяйте визуальную непрерывность и владение моделью

В примере исходная карточка остаётся видимой, пока принятый сброс не изменит isOnRight. Если отпустить её за пределами панелей, модель не изменится. Если сбросить карточку обратно в занятую панель, существующее местоположение будет присвоено повторно. Несовпадающий токен или несколько элементов отклоняются до изменения состояния.

Такое поведение избавляет от необходимости удалять карточку в момент начала перетаскивания и затем восстанавливать её после отмены. Предпросмотр обеспечивает визуальную непрерывность; место назначения отвечает за изменение местоположения. Для более крупного поля примените тот же подход к стабильному идентификатору элемента и единой авторитетной коллекции.

Семантика значений в Swift помогает понять, почему передача данных сама по себе не перемещает исходную модель. Структуры и строки имеют семантику значений; их присваивание или передача не перемещает автоматически элемент в коллекции приложения. Такое перемещение должно быть выражено обновлением модели. См. Структуры и классы.

Также различайте перемещение на уровне приложения и нативную операцию перетаскивания. В примере одно общее состояние поля изменяется после принятого сброса; нативные правила согласования операции перемещения не настраиваются, и курсор перемещения не гарантируется.

Для macOS 26 и новее Apple описывает DragConfiguration для объявления поддерживаемых операций и onDragSessionUpdated(_:) для отслеживания выполненной операции. Место назначения выбирает выполняемую операцию. Если после нативного перемещения вы реализуете удаление элемента из источника, согласуйте его с обработчиком места назначения, чтобы один и тот же общий элемент не был удалён дважды. См. DragConfiguration и onDragSessionUpdated(_:).

Когда сеансом перетаскивания должен управлять AppKit?

Используйте AppKit, если критерий приёмки требует явно заданного положения изображения перетаскивания, например, чтобы при захвате указатель оставался над той же точкой внутри карточки. Документированный модификатор пользовательского предпросмотра SwiftUI принимает данные и представление предпросмотра и описывает предпросмотр, расположенный по центру. AppKit напрямую предоставляет доступ к кадру перетаскиваемого элемента.

При реализации на AppKit выполните следующие шаги:

  1. Пусть исходное представление NSView получит фактическое событие нажатия кнопки мыши и отслеживает, перерастёт ли взаимодействие в перетаскивание.
  2. Отобразите всю синтетическую карточку в одно изображение NSImage и создайте один NSDraggingItem с объектом записи в буфер обмена.
  3. Задайте изображение и его начальный прямоугольник с помощью setDraggingFrame(_:contents:).
  4. Выразите этот прямоугольник в системе координат исходного представления, затем вызовите beginDraggingSession(with:event:source:), передав событие исходного нажатия кнопки мыши и свой NSDraggingSource.
  5. Позвольте месту назначения проверить и обработать сброс. Для очистки сеанса используйте обратный вызов завершения источника.

Apple указывает, что положение мыши в исходном событии определяет смещение перетаскиваемого значка, а начальный кадр каждого элемента должен задаваться в координатах исходного представления. Используйте согласованную систему координат для прямоугольника карточки и положения указателя. См. beginDraggingSession(with:event:source:).

setDraggingFrame(_:contents:) — документированный вспомогательный метод для перетаскиваемого элемента, представленного одним изображением. Синтетическая карточка может содержать несколько нарисованных элементов и при этом быть представлена этим единственным изображением. См. setDraggingFrame(_:contents:).

Сохраняйте границы событий и сеанса

Выберите один инициатор перетаскивания для интерактивной области карточки. В качестве правила реализации избегайте наложения запуска сеанса AppKit на активный источник draggable в SwiftUI для той же области. Иначе оба механизма попытаются управлять взаимодействием. Не создавайте искусственные события мыши, чтобы компенсировать неясное распределение ответственности за жесты.

Встроить источник AppKit можно с помощью NSViewRepresentable. Оставьте обработку нативных событий в его NSView, а принятые изменения модели передавайте через привязки или координатор. SwiftUI управляет компоновкой представленного представления; Apple отдельно указывает, что нельзя напрямую задавать frame или bounds этого управляемого представления. Прямоугольник изображения элемента перетаскивания — отдельный вопрос. См. NSViewRepresentable.

Не сохраняйте NSDraggingItem, чтобы изменить его позже. Apple документирует ограниченный срок его жизни: после создания сеанса меняйте элементы перетаскивания через перечисление элементов сеанса и используйте каждый перечисленный элемент только внутри его обратного вызова. Изменения со стороны места назначения также могут переопределить изменения изображения со стороны источника, пока перетаскивание находится над этим местом назначения. См. NSDraggingItem и enumerateDraggingItems(options:for:classes:searchOptions:using:).

Как диагностировать проблемы с предпросмотром?

Проверьте сбой на том этапе, где он возникает:

Симптом Что проверить в первую очередь
Перемещается только круг или текст Разместите один источник перетаскивания на контейнере всей карточки.
Размеры предпросмотра отличаются от карточки Повторно используйте то же представление и задайте явные размеры.
Контур при захвате отличается от движущегося изображения Проверьте .dragPreview отдельно от замыкания пользовательского предпросмотра.
Перетаскивание неожиданно начинается с пустого места Проверьте .interaction и границы контейнера-источника.
Изображение AppKit скачет при захвате Проверьте исходное событие нажатия кнопки мыши и координаты исходного представления.
Последующие изменения изображения AppKit ничего не дают Выполняйте их внутри перечисления элементов сеанса.
Карточка исчезает после отмены Перенесите изменение модели в обработчик принятого сброса и проверьте очистку.

Отдельно проверьте отмену, принятый сброс, сброс обратно на исходную панель и отклонённые данные. Полезный инвариант состоит в том, что во всё время перетаскивания авторитетным остаётся одно местоположение модели. Начните с полного предпросмотра SwiftUI и переходите к источнику под управлением AppKit только тогда, когда требуемое размещение изображения требует явного управления его кадром и событиями.

Источники