При перетаскивании внутри приложения для Mac источник объявляет поддерживаемые операции, назначение выбирает одну из них, а ваша модель выполняет результат. В SwiftUI для macOS 26 или новее настройте обе стороны и удаляйте оригинал только после завершённого перемещения.

В этой статье создаётся простое взаимодействие для настольного приложения: перетащите синтетический круг в область Copy here или Move here. Все данные остаются в памяти. В примере явно заданы операция, идентификатор элемента и очистка источника, а затем те же обязанности сопоставлены с AppKit.

О чём должны договориться источник и назначение?

Передаваемые данные описывают, что перемещается между представлениями. Копирование или перемещение описывает, что происходит с оригиналом. Это отдельные решения. В документации Apple по SwiftUI сказано, что по умолчанию перетаскивание поддерживает копирование; включение перемещения добавляет ещё одну поддерживаемую операцию. Затем назначение выбирает способ получения элемента. DragConfiguration

При проектировании взаимодействия предусмотрите три обязанности:

Обязанность Копирование Перемещение
Возможности источника Предложить копирование Предложить перемещение
Подтверждение на стороне назначения Вставить независимый элемент Получить существующий элемент
Завершение на стороне источника Оставить оригинал Удалить оригинал после завершённого перемещения

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

Практический вывод: возврат операции перемещения сам по себе не переносит элемент между вашими массивами. Вам всё равно нужно вставить его в месте назначения и удалить из источника. Apple отдельно показывает, как отслеживать завершённую операцию перетаскивания, чтобы сообщить модели, когда следует удалить исходный элемент. onDragSessionUpdated(_:)

Какие API SwiftUI следует использовать в приложении для Mac?

Для этой реализации укажите целевую версию macOS 26 или новее и используйте SDK с документированными модификаторами конфигурации. Добавьте draggable(_:) к источнику, dragConfiguration(_:) для объявления его операций и dropConfiguration(_:) для выбора операции в месте назначения. В документации оба модификатора конфигурации доступны начиная с macOS 26. dragConfiguration(_:), dropConfiguration(_:)

Текущая перегрузка dropDestination(for:isEnabled:action:) передаёт объект DropSession своему обработчику. Обработчик ничего не возвращает (Void). Это отличается от старой перегрузки, обработчик которой получает координаты и возвращает логическое значение. В примере параметр isEnabled: задан явно, чтобы было понятно, какая перегрузка используется. dropDestination(for:isEnabled:action:)

В месте назначения проверьте suggestedOperations перед выбором операции. Для универсальной цели, отдающей предпочтение перемещению, выберите перемещение, если оно доступно; иначе выберите копирование, если оно доступно; в противном случае запретите операцию. В документированном примере Apple используется такой переход от перемещения к копированию; там также отмечено, что замыкание конфигурации вызывается часто, поэтому оно должно выполняться быстро. dropConfiguration(_:)

У двух целей в нашем примере намеренно заданы фиксированные значения. Copy here принимает копирование, а Move here — перемещение. Цель возвращает запрет, если указанная для неё операция недоступна. Так результат остаётся предсказуемым и не зависит от клавиш-модификаторов.

Полный пример копирования и перемещения в памяти

Создайте приложение SwiftUI для macOS, укажите целевую версию macOS 26 или новее и замените сгенерированные объявления приложения и представления содержимого следующим файлом. Для передачи элемента используется встроенная строка с его идентификатором, поэтому пользовательский тип transferable и объявление пользовательского типа не нужны. Apple описывает как transferable-данные для источников перетаскивания, так и строковые данные для места назначения. draggable(_:), dropDestination(for:isEnabled:action:)

Это полностью готовое минимальное приложение, рассчитанное на одно открытое окно и один исходный элемент. Я сверил ссылки на API с полученной документацией; скомпилировать или запустить приложение здесь не удалось, поскольку Swift и SDK macOS недоступны.

import SwiftUI
import Foundation

@main
struct CopyMoveDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CopyMoveDemo()
        }
    }
}

struct CopyMoveDemo: View {
    @State private var sourceID = UUID()
    @State private var sourcePresent = true
    @State private var copiedIDs: [UUID] = []
    @State private var movedIDs: [UUID] = []
    @State private var moveReceived = false

    private var sourceConfiguration: DragConfiguration {
        DragConfiguration(
            operationsWithinApp: .init(allowMove: true),
            operationsOutsideApp: .init(
                allowCopy: false,
                allowMove: false,
                allowDelete: false
            )
        )
    }

    var body: some View {
        VStack(spacing: 24) {
            Text("Drag the circle to a target")
                .font(.headline)

            if sourcePresent {
                Circle()
                    .fill(.blue)
                    .frame(width: 90, height: 90)
                    .overlay(Text("Circle").foregroundStyle(.white))
                    .draggable(sourceID.uuidString)
                    .dragConfiguration(sourceConfiguration)
                    .onDragSessionUpdated { session in
                        if session.phase == .initial {
                            moveReceived = false
                        }
                        if session.phase == .ended(.move), moveReceived {
                            sourcePresent = false
                        }
                        if case .ended = session.phase {
                            moveReceived = false
                        }
                    }
            } else {
                Text("Original moved")
                    .frame(width: 90, height: 90)
            }

            HStack(alignment: .top, spacing: 20) {
                target(moving: false)
                target(moving: true)
            }
        }
        .padding(24)
        .frame(minWidth: 520, minHeight: 320)
    }

    private func target(moving: Bool) -> some View {
        let operation: DropOperation = moving ? .move : .copy
        let ids = moving ? movedIDs : copiedIDs

        return VStack(spacing: 10) {
            Text(moving ? "Move here" : "Copy here")
                .font(.headline)
            ForEach(ids, id: \.self) { id in
                Text("Circle \(id.uuidString.prefix(4))")
            }
            Spacer(minLength: 20)
        }
        .frame(width: 210, height: 150)
        .background(.blue.opacity(0.08))
        .contentShape(Rectangle())
        .dropDestination(for: String.self, isEnabled: true) {
            tokens, session in
            guard session.localSession != nil,
                  sourcePresent,
                  tokens == [sourceID.uuidString] else {
                return
            }
            if moving {
                guard !movedIDs.contains(sourceID) else { return }
                movedIDs.append(sourceID)
                moveReceived = true
            } else {
                copiedIDs.append(UUID())
            }
        }
        .dropConfiguration { session in
            guard sourcePresent,
                  session.localSession != nil,
                  session.itemsCount == 1,
                  session.suggestedOperations.contains(operation) else {
                return DropConfiguration(operation: .forbidden)
            }
            return DropConfiguration(operation: operation)
        }
    }
}

Конфигурация источника предлагает копирование и перемещение внутри приложения. Она отключает операции за пределами приложения с помощью инициализатора для операций вне приложения на macOS. Место назначения также проверяет localSession, который, согласно документации Apple, отсутствует, если перетаскивание началось в другом месте. Эти настройки позволяют сосредоточить пример на элементе в памяти собственного окна. DragConfiguration.OperationsWithinApp, init(allowCopy:allowMove:allowDelete:), DropSession

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

Текущее объявление фаз перетаскивания Apple включает initial, active, ending, ended и dataTransferCompleted. В примере для результата операции используется документированный случай ended(DropOperation). Источник не удаляется во время наведения или на более ранней фазе завершения. DragSession.Phase

Чётко задайте владельца данных и границы жеста

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

Для перетаскивания нескольких элементов используйте стабильные идентификаторы модели и, когда это возможно, получайте идентификаторы перетаскиваемых элементов из сессии. Apple предоставляет draggedItemIDs(for:) для идентифицируемых данных или отдельно переданных идентификаторов. Не удаляйте элементы, выбранные в момент завершения: выбранные элементы и идентификаторы перетаскиваемых элементов — это разные данные. DragSession

Пусть модификатор draggable(_:) в SwiftUI устанавливает взаимодействие перетаскивания. Не удаляйте модель при создании данных для передачи. В документации Apple сказано, что этот модификатор добавляет нужные жесты, а замыкание для экспорта не следует отправлять на главный актор. draggable(_:)

Как работает такое же согласование в AppKit?

Используйте AppKit, если целевая версия macOS или существующая архитектура представлений предполагает явный жизненный цикл перетаскивания. Протокол источника объявляет разрешённые операции через draggingSession(_:sourceOperationMaskFor:). Контекст позволяет различать места назначения внутри и за пределами приложения. Для описанного взаимодействия разрешите копирование и перемещение внутри приложения и запретите операции за его пределами. draggingSession(_:sourceOperationMaskFor:)

Место назначения регистрируется для соответствующего типа pasteboard, затем выбирает операцию в draggingEntered(_:) и при необходимости обновляет выбор в draggingUpdated(_:). Apple требует учитывать пересечение операций источника с поддерживаемыми операциями и типами данных места назначения. Возвращайте одну операцию либо ни одной, если подходящего пересечения нет. NSDraggingDestination, draggingEntered(_:)

Вставку выполняйте в performDragOperation(_:) после успешной подготовки. Возвращайте true только в том случае, если место назначения принимает данные. Источник получает сведения о завершённой операции через draggingSession(_:endedAt:operation:); используйте их вместе с правилами владения в модели, чтобы при перемещении выполнить удаление со стороны источника. performDragOperation(_:), draggingSession(_:endedAt:operation:)

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

Старый код места назначения в SwiftUI может возвращать DropProposal(operation: .move) из DropDelegate.dropUpdated(info:). Так задаётся предложение места назначения. Это не заменяет настройку возможностей источника, отслеживание завершённой операции или изменения модели. Если новые API источника SwiftUI недоступны, а вам нужен явный контроль обеих сторон, используйте жизненный цикл источника и места назначения AppKit. dropUpdated(info:), NSDraggingSource

Проверяйте и операцию, и модель

Для примера используйте следующий список ожидаемых состояний. Это критерии приёмки, выведенные из реализации, а не результаты проведённых тестов:

Действие Оригинал Цель копирования Цель перемещения
Перетащить один раз в Copy here Остаётся Получает новый идентификатор Без изменений
Копировать ещё раз Остаётся Получает ещё один новый идентификатор Без изменений
Перетащить в Move here Исчезает после завершённого перемещения Без изменений Получает исходный идентификатор
Отменить перетаскивание или отпустить вне обеих целей Остаётся Без изменений Без изменений

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

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

Источники