🦋
SwiftUI: Transferableでのドラッグ&ドロップ
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