SwiftUI の Table で行のドラッグ&ドロップを実現する方法

SwiftUIの Table でリスト行をドラッグ&ドロップ可能にするまでに、いくつもの罠を踏んだので記録として残しておきます。

検証環境

  • Xcode: 26.3
  • Swift: 6.2.3
  • macOS: 26.4 (Tahoe)
  • 最小デプロイターゲット: macOS 15+

結論

Table(of:columns:rows:) イニシャライザを使い、TableRow.draggable() を付ける。データ型は Transferable に準拠させる。

Table(of: MyItem.self, selection: $selection, sortOrder: $sortOrder) {
    TableColumn("名前", value: \.name) { item in
        Text(item.name)
    }
    // ... 他のカラム
} rows: {
    ForEach(items) { item in
        TableRow(item)
            .draggable(item)
    }
}

うまくいかなかったアプローチ

1. セルに .gesture(DragGesture) を付ける

TableColumn("名前", value: \.name) { item in
    Text(item.name)
        .gesture(DragGesture(minimumDistance: 5).onChanged { ... })
}

結果: ドラッグは動くが、テーブルの行選択が壊れる。DragGesture がクリックイベントを奪ってしまう。

2. セルに .onDrag を付ける

TableColumn("名前", value: \.name) { item in
    Text(item.name)
        .onDrag { NSItemProvider(...) }
}

結果: .gesture と同様に行選択が壊れる。さらに NSItemProvider は1つしか返せないため複数ファイルのドラッグができない。

3. NSTableView の dataSource をプロキシで差し替える

SwiftUI の Table の裏にいる NSTableView をビュー階層から掘り出し、dataSource をプロキシオブジェクトでラップして pasteboardWriterForRow だけ追加するアプローチ。

結果: SwiftUI が dataSource を内部で再設定するため安定しない。ビルドは通るが実行時にクラッシュや無反応。

4. Table 全体に .simultaneousGesture を付ける

Table(...)
    .simultaneousGesture(
        DragGesture(minimumDistance: 15).onChanged { ... }
    )

結果: テーブルがジェスチャーを内部で完全に管理しているため、外部のジェスチャーが発火しない。

正解: Table(of:columns:rows:) + TableRow.draggable

ポイント1: Table のイニシャライザが違う

よく使われる Table(data, columns:) ではなく、columns:rows: を分離したイニシャライザを使う必要がある。

// これではドラッグできない
Table(items, selection: $selection) {
    TableColumn(...) { ... }
}

// こちらを使う
Table(of: MyItem.self, selection: $selection) {
    TableColumn(...) { ... }
} rows: {
    ForEach(items) { item in
        TableRow(item)
            .draggable(item)  // ここがキモ
    }
}

ポイント2: Transferable 準拠

.draggable() を使うにはデータ型が Transferable に準拠している必要がある。

ファイルをドラッグしたい場合、ProxyRepresentationURL を返すのがシンプル:

import CoreTransferable

extension MyItem: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        ProxyRepresentation { item in
            item.fileURL
        }
    }
}

ポイント3: FileRepresentation ではなく ProxyRepresentation

FileRepresentation は非同期クロージャでファイルを生成する。@Observable なクラスのプロパティにバックグラウンドスレッドからアクセスするとクラッシュする。

// 危険: バックグラウンドスレッドで @Observable のプロパティにアクセス
FileRepresentation(exportedContentType: .fileURL) { item in
    let data = item.compressedData  // クラッシュの可能性
    // ...
}

// 安全: メインスレッドで同期的に URL を返す
ProxyRepresentation { item in
    item.exportURL  // computed property でテンプファイルを生成して返す
}

完成形のコード

import CoreTransferable

// --- モデル ---

@Observable
final class MyItem: Identifiable {
    let id = UUID()
    let originalURL: URL
    var compressedData: Data?

    var exportURL: URL {
        if let data = compressedData {
            let tempDir = FileManager.default.temporaryDirectory
                .appendingPathComponent("MyApp", isDirectory: true)
            try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
            let url = tempDir.appendingPathComponent(originalURL.lastPathComponent)
            try? data.write(to: url)
            return url
        }
        return originalURL
    }
}

extension MyItem: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        ProxyRepresentation { item in
            item.exportURL
        }
    }
}

// --- ビュー ---

struct MyTableView: View {
    @State private var items: [MyItem] = []
    @State private var selection: Set<UUID> = []

    var body: some View {
        Table(of: MyItem.self, selection: $selection) {
            TableColumn("ファイル名") { item in
                Text(item.originalURL.lastPathComponent)
            }
            TableColumn("サイズ") { item in
                Text("\(item.originalURL)")
            }
        } rows: {
            ForEach(items) { item in
                TableRow(item)
                    .draggable(item)
            }
        }
    }
}

まとめ

方法選択ドラッグ評価
セルに .gesture(DragGesture)NGOK使えない
セルに .onDragNGOK使えない
NSTableView dataSource 差し替えOKNG不安定
.simultaneousGestureOKNG発火しない
TableRow.draggableOKOK正解

SwiftUI の Table でドラッグを実現するには、Table(of:columns:rows:) イニシャライザ + TableRow.draggable が唯一の正攻法。ドキュメントにはほぼ書かれていないが、この組み合わせでないと選択とドラッグが共存できない。

Finderのリストビューでは当たり前のようにドラッグできるので、なぜSwiftUIで簡単にいかないのかよくわからんが、粘った甲斐があ離ました。

下記の記事がとても参考になりました。ありがとうございました。

参考