🦋

SwiftUI: Transferableでのドラッグ&ドロップ

2024/11/25に公開

SwiftUIでは構造体をTransferableに準拠させることで、要素のドラッグ&ドロップが簡単に実装できます。


左右のリストでアイテムを行き来できるサンプル

struct Item: Codable, Identifiable, Transferable {
    var id: UUID
    var value: Int

    init(id: UUID = UUID(), value: Int) {
        self.id = id
        self.value = value
    }

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: Item.self, contentType: .data)
    }
} 
struct ContentView: View {
    @State var leftItems: [Item] = (0 ..< 5).map { Item(value: $0) }
    @State var rightItems = [Item]()

    var body: some View {
        HStack(spacing: 0) {
            List {
                Group {
                    ForEach(leftItems) { item in
                        Text("Item: \(item.value)")
                            .draggable(item)
                    }
                    if leftItems.isEmpty {
                        Text("No Item")
                    }
                }
                .dropDestination(for: Item.self) { items, _ in
                    leftItems.append(contentsOf: items)
                    rightItems.removeAll { item in items.contains(where: { $0.id == item.id }) }
                    return true
                }
            }
            List {
                Group {
                    ForEach(rightItems) { item in
                        Text("Item: \(item.value)")
                            .draggable(item)
                    }
                    if rightItems.isEmpty {
                        Text("No Item")
                    }
                }
                .dropDestination(for: Item.self) { items, _ in
                    rightItems.append(contentsOf: items)
                    leftItems.removeAll { item in items.contains(where: { $0.id == item.id }) }
                    return true
                }
            }
        }
    }
}
  • Codableに準拠していれば、Transferableへも簡単に準拠できる
  • 移動したいものにdraggable()をつける
  • 移動を受け入れる場所にdropDestination()をつける(これはつけるViewによって引数が異なる別関数で挙動も異なるので注意)

contentTypeを独自のタイプで指定する

TransferRepresentationを返す際にcontentTypeを指定しますが、これは基本的には独自のタイプを指定した方が良いです。

static var transferRepresentation: some TransferRepresentation {
    CodableRepresentation(for: Item.self, contentType: .data)
}

サンプルとして、com.org.myItemというIdentifierを持つmyItemタイプを定義します。

import UniformTypeIdentifiers

extension UTType {
    static var myItem: UTType { UTType(exportedAs: "com.org.myItem") }
}

// UTType.myItemをTransferRepresentationで指定する
static var transferRepresentation: some TransferRepresentation {
    CodableRepresentation(for: Item.self, contentType: .myItem)
}

続いて、プロジェクトのInfo.plistにExported Type Identifiersを追加します。
これをしないと独自のタイプがちゃんと動きません。

  • UTTypeIdentifier
  • UTTypeConformsTo
  • UTTypeTagSpecification

あたりが適切に設定できていれば良さそうです。

Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>UTExportedTypeDeclarations</key>
	<array>
		<dict>
			<key>UTTypeConformsTo</key>
			<array>
				<string>public.data</string>
			</array>
			<key>UTTypeDescription</key>
			<string>My Item</string>
			<key>UTTypeIconFiles</key>
			<array/>
			<key>UTTypeIdentifier</key>
			<string>com.org.myItem</string>
			<key>UTTypeTagSpecification</key>
			<dict>
				<key>public.filename-extension</key>
				<array>
					<string>myItem</string>
				</array>
			</dict>
		</dict>
	</array>
</dict>
</plist>

複数のTransferable構造体をドロップできるようにする

複数のアイテムを一箇所にドロップさせようとする際には注意が必要です。

  • Transferable構造体ごとにdropDestination(for:)をつけても、最後のモディファイアしか効力を持たない
  • Transferable構造体のcontentTypeが同一だと見分けがつかなくてバグる


ダメな例1:アイテムがドロップできない


ダメな例2:アイテムはドロップできるが機能しない

ダメな例のソース
struct ItemA: Codable, Identifiable, Transferable {
    var id: UUID
    var value: Int

    init(id: UUID = UUID(), value: Int) {
        self.id = id
        self.value = value
    }

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: ItemA.self, contentType: .data)
    }
}

struct ItemB: Codable, Identifiable, Transferable {
    var id: UUID
    var value: String

    init(id: UUID = UUID(), value: String) {
        self.id = id
        self.value = value
    }

    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: ItemB.self, contentType: .data)
    }
}

struct ContentView: View {
    @State var itemsA: [ItemA] = (0 ..< 5).map { ItemA(value: $0) }
    @State var itemsB: [ItemB] = ["🥚", "🐤", "🐔"].map { ItemB(value: $0) }
    @State var rightItems = [Any]()

    var body: some View {
        HStack(spacing: 0) {
            VStack(spacing: 0) {
                List {
                    ForEach(itemsA) { item in
                        Text("ItemA: \(item.value)")
                            .draggable(item)
                    }
                }
                List {
                    ForEach(itemsB) { item in
                        Text("ItemB: \(item.value)")
                            .draggable(item)
                    }
                }
            }
            List {
                Group {
                    ForEach(rightItems.indices, id: \.self) { i in
                        switch rightItems[i] {
                        case let item as ItemA:
                            Text("ItemA: \(item.value)")
                        case let item as ItemB:
                            Text("ItemB: \(item.value)")
                        default:
                            EmptyView()
                        }
                    }
                    if rightItems.isEmpty {
                        Text("No Item")
                    }
                }
                .dropDestination(for: ItemA.self) { items, _ in
                    rightItems.append(contentsOf: items)
                    itemsA.removeAll { item in items.contains(where: { $0.id == item.id }) }
                    return true
                }
                .dropDestination(for: ItemB.self) { items, _ in
                    rightItems.append(contentsOf: items)
                    itemsB.removeAll { item in items.contains(where: { $0.id == item.id }) }
                    return true
                }
            }
        }
    }
}

そのため、以下のような対応が必要です。

  • dropDestination(for:)は一つのViewに対して一つしかつけない
    • そのためにenumのassociated valueを活用する
  • contentTypeはTransferable構造体ごとに独自のタイプで定義する
struct ItemA: Codable, Identifiable, Transferable {
    var id: UUID
    var value: Int

    init(id: UUID = UUID(), value: Int) {
        self.id = id
        self.value = value
    }

    static var transferRepresentation: some TransferRepresentation {
        // 独自のタイプを指定する
        CodableRepresentation(for: ItemA.self, contentType: .itemA)
    }
}

struct ItemB: Codable, Identifiable, Transferable {
    var id: UUID
    var value: String

    init(id: UUID = UUID(), value: String) {
        self.id = id
        self.value = value
    }

    static var transferRepresentation: some TransferRepresentation {
        // 独自のタイプを指定する
        CodableRepresentation(for: ItemB.self, contentType: .itemB)
    }
}

extension UTType {
    static var itemA: UTType { UTType(exportedAs: "com.org.itemA") }
    static var itemB: UTType { UTType(exportedAs: "com.org.itemB") }
}

enum Item: Transferable {
    case a(ItemA)
    case b(ItemB)

    static var transferRepresentation: some TransferRepresentation {
        // ここは書いた順に優先してハンドリングされる
        ProxyRepresentation(importing: { Item.a($0) })
        ProxyRepresentation(importing: { Item.b($0) })
    }
}
struct ContentView: View {
    @State var itemsA: [ItemA] = (0 ..< 5).map { ItemA(value: $0) }
    @State var itemsB: [ItemB] = ["🥚", "🐤", "🐔"].map { ItemB(value: $0) }
    @State var rightItems = [Any]()

    var body: some View {
        HStack(spacing: 0) {
            VStack(spacing: 0) {
                List {
                    ForEach(itemsA) { item in
                        Text("ItemA: \(item.value)")
                            .draggable(item)
                    }
                }
                List {
                    ForEach(itemsB) { item in
                        Text("ItemB: \(item.value)")
                            .draggable(item)
                    }
                }
            }
            List {
                Group {
                    ForEach(rightItems.indices, id: \.self) { i in
                        switch rightItems[i] {
                        case let item as ItemA:
                            Text("ItemA: \(item.value)")
                        case let item as ItemB:
                            Text("ItemB: \(item.value)")
                        default:
                            EmptyView()
                        }
                    }
                    if rightItems.isEmpty {
                        Text("No Item")
                    }
                }
                // dropDestinationは一つにまとめる
                .dropDestination(for: Item.self) { items, _ in
                    guard let item = items.first else { return false }
                    switch item {
                    case let .a(itemA):
                        rightItems.append(itemA)
                        itemsA.removeAll { item in item.id == itemA.id }
                    case let .b(itemB):
                        rightItems.append(itemB)
                        itemsB.removeAll { item in item.id == itemB.id }
                    }
                    return true
                }
            }
        }
    }
}

Discussion