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 に準拠している必要がある。
ファイルをドラッグしたい場合、ProxyRepresentation で URL を返すのがシンプル:
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) | NG | OK | 使えない |
セルに .onDrag | NG | OK | 使えない |
| NSTableView dataSource 差し替え | OK | NG | 不安定 |
.simultaneousGesture | OK | NG | 発火しない |
TableRow.draggable | OK | OK | 正解 |
SwiftUI の Table でドラッグを実現するには、Table(of:columns:rows:) イニシャライザ + TableRow.draggable が唯一の正攻法。ドキュメントにはほぼ書かれていないが、この組み合わせでないと選択とドラッグが共存できない。
Finderのリストビューでは当たり前のようにドラッグできるので、なぜSwiftUIで簡単にいかないのかよくわからんが、粘った甲斐があ離ました。
下記の記事がとても参考になりました。ありがとうございました。
