[Swift][SwiftUI] Transferable で実装する Drag&Drop

SwiftUI2021

     
⌛️ 6 min.
WWDC2022 で登場した Transferable で Drag & Drop を実装してみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Ventura 13
  • Xcode 14.1 beta 3
  • iOS 16.0

Transferable 概要

Transferable は、Drag&Drop や Copy&Paster で要素を扱うための Protocol です。

Apple のドキュメントは、こちら

以前は、NSItemProvider を使って実現していましたが、Transferable を使用すると、よりシンプルに記述できるようになります。

NSItemProvider を使った Drag&Drop の実装は以下の記事で解説しています。
SwiftUI [SwiftUI] SwiftUI で実装する Drag&Drop (その1: String を Drag&Drop)
SwiftUI [SwiftUI] SwiftUI で実装する Drag&Drop (その2:クラスオブジェクト を Drag&Drop)
SwiftUI [SwiftUI] SwiftUI Drag&Drop implementation(Vol. 3:use own UTI in Drag&Drop)

String の Drag&Drop

Transfereable を使用して String の Drag&Drop を実装してみます。

Drag&Drop を実装するアプリは、左右にリストがあるアプリとします。左側のリストの要素を右側のリストに D&D すると、要素として追加される という動作です。

struct ContentView: View {
    @State private var leftListItems = ["Hello", "world"]
    @State private var rightListItems = ["こんにちわ", "世界"]
    var body: some View {
        HStack {
            List {
                ForEach(leftListItems, id: \.self) { item in
                    Text(item)
                }
            }
            List {
                ForEach(rightListItems, id: \.self) { item in
                    Text(item)
                }
            }
        }
        .padding()
    }
}

以下のように2つのリストがあり、左側のリストの要素を右側のリストに Drag&Drop できるようにします。

TwoLists

draggable

まずは、左側のリストの要素を Drag できるようにします。

View Modifier の .draggable を使用します。draggable の引数には、Transferable を返すような closure を指定します。
Apple のドキュメントは、こちら

func draggable(_ payload: @autoclosure @escaping () -> T) -> some View where T : Transferable

draggable では、ドラッグ先に渡すデータを返す closure を指定します。渡すデータは、 Transferable に準拠している必要があります。
今回のケースで扱っている String は、すでに Transferable に準拠しているので、そのまま渡しています。

struct ContentView: View {
    @State private var leftListItems = ["Hello", "world"]
    @State private var rightListItems = ["こんにちわ", "世界"]
    var body: some View {
        HStack {
            List {
                ForEach(leftListItems, id: \.self) { item in
                    Text(item)
                        .draggable(item) // 🆕 追加
                }
            }
            List {
                ForEach(rightListItems, id: \.self) { item in
                    Text(item)
                }
            }
        }
        .padding()
    }
}

上記のようにすることで、左側のリストの要素をドラッグできるようになります。

この状態で 例えば、メモ帳にドラッグすると、ドラッグした文字列がメモ帳に貼り付けられます。

dropDestination

次に、右側のリストが Drop を受け付けられるようにします。

Drop を受け付けられるように、List の View Modifier の dropDestination を使用します。

Apple のドキュメントは、こちら


func dropDestination<T>(
    for payloadType: T.Type = T.self,
    action: @escaping ([T], CGPoint) -> Bool,
    isTargeted: @escaping (Bool) -> Void = { _ in }
) -> some View where T : Transferable

1つ目の引数として受け付けるデータを指定して、2つ目の引数は、ドロップされたときの動作を記述します。
2つめの引数である closure は、データ受け付けができた場合は、true を、データを受け付けられなかった時には、 false を返すことが必要です。

struct ContentView: View {
    @State private var leftListItems = ["Hello", "world"]
    @State private var rightListItems = ["こんにちわ", "世界"]
    var body: some View {
        HStack {
            List {
                ForEach(leftListItems, id: \.self) { item in
                    Text(item)
                        .draggable(item)
                }
            }
            List {
                ForEach(rightListItems, id: \.self) { item in
                    Text(item)
                }
            }
            .dropDestination(for: String.self) { items in  // 🆕 追加
                rightListItems.append(contentsOf: items)
                return true
            }
        }
        .padding()
    }
}

上記のようにすることで、リストが String のデータをもつような Drop を受け付けるようになります。
ここでは、受け付けた要素を 右側のリストに追加しています。常に受け付けは成功していますので、true を返しています。

Transferable 詳細

String の例は、String が Transferable に準拠していることを利用して動作しています。

Transferable に準拠するとはどのようなことかを確認してみます。

Transferable protocol

Transferable は、iOS16/ macOS 13 で新しく導入された Protocol です。

@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public protocol Transferable {
    associatedtype Representation : TransferRepresentation
    @TransferRepresentationBuilder<Self> static var transferRepresentation: Self.Representation { get }
}

Apple のドキュメントは、こちら

ある型が Transferable に準拠するには、static な (get できる) transferRepresentation を定義することが必要です。

TransferRepresentation も、iOS 16/macOS 13 で導入された protocol です。準拠することで、データをやり取りする際にデータ変換できる型とその変換方法を定義します。

Apple のドキュメントは、こちら

この TransferRepresentation を使うことで、複数の表現方法をサポートすることができます。

独自型を Transferable に準拠する

実際に、型を Tranferable に準拠させて、 Drag&Drop で使ってみます。

独自型の定義

準拠させる型は、以下の型とします。Color と String を持つ struct です。

struct NamedColor {
    let name: String
    let color: Color
}

struct を直接渡せそうであれば、そのまま渡します。それ以外のケースとして、Color を受け取れそうであれば Color を、String を受け取れそうであれば、String を渡すようにしてみます。

Transferable に準拠

Transferable に準拠させるということは、以下を定義するということです。

static var transferRepresentation: some TransferRepresentation

このプロパティが、実際に渡されるデータを定義しています。

定義を辿ってみると TransferRepresentationBuilder という名前の Property Wrapper が付与されていることからも、この属性も Builder 系の属性です。

つまり、複数の定義を同梱できる属性です。ですが、まずは、1つの表現だけにしておきます。

TransferRepresentation 定義

Codable な型であれば、Codable であることを利用して、以下のように書けます。

extension NamedColor: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: NamedColor.self, contentType: .namedColor)
    }
}

つまり、先に定義した NamedColor を Codable に準拠させるようにします。

自前の既存コードを使って、以下のように Codable に準拠させました。

extension NamedColor: Codable {
    private enum CodingKeys:  CodingKey {
        case name, rgbString
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        let rgbStr = color.rgbValueStr
        try container.encode(rgbStr, forKey: .rgbString)
    }

    public init(from decoder: Decoder) throws {
        let colorContainer = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try colorContainer.decode(String.self, forKey: .name)
        let rgbStr = try colorContainer.decode(String.self, forKey: .rgbString)
        guard let color = Color(hexStr: rgbStr) else { fatalError("invalid color")}
        self.color = color
    }
}

String は、それ単体で Codable なのですが、Color は、Codable に準拠していません。
上記では、Color から RGB を使った文字列表現に変換して保存するようにしています。

ここでは、NamedColor を Codable に準拠させることがポイントです。どのように準拠させるかは本質ではありません。

UTType

Drag&Drop できるようにするということは、アプリ内で閉じる使い方だけではなく、別アプリとのやり取りにも使用されるかもしれません。

macOS では、UniformTypeIdentifier というものを使って、受け渡し含めデータを管理しています。

独自型についても、UniformTypeIdentifier を定義することが必要となります。
今回は、com.smalldesksoftware.example.namedcolor という識別子の UTType を定義しました。
Info.plist にも同様に登録することが必要です。

extension UTType {
    static var namedColor = UTType(exportedAs: "com.smalldesksoftware.example.namedcolor")
}

UTType の扱いについては、以下の記事で説明しています。
[Swift][macOS][iOS]UTType の登録方法

独自型を Drag&Drop する

NamedColor を Transferable に準拠させることができたので、Drag&Drop できるようアプリを作ります。

左側に、3つの NamedColor を配置して、右側には受ける側の要素を配置しました。

NamedColor

以下がコードです。

struct ContentView: View {
    @State private var colorName: String = "NoColor"
    @State private var color: Color = .clear
    var body: some View {
        HStack {
            VStack {
                Color.blue.frame(width: 100, height: 100)
                Color.yellow.frame(width: 100, height: 100)
                Color.red.frame(width: 100, height: 100)
            }
            VStack {
                Text(colorName)
                    .frame(width: 100, height: 100)
                    .background {
                        Rectangle()
                            .fill(color)
                            .frame(width: 100, height: 100)
                            .border(.black)
                    }
            }
        }
    }
}

左側に配置された色の四角を、右側にドロップすることで、ドロップされた Color と String で、変数 color と colorName を更新するイメージです。

独自型を Drag できるようにする

最初の例では、String を Drag できるようにしたので、String を直接 draggable に渡していました。先ほど NamedColor もTransferable に準拠させましたので、NamedColor も draggable に直接渡せるようになっています。ということで、以下のようなコードになります。

struct ContentView: View {
    @State private var colorName: String = "NoColor"
    @State private var color: Color = .clear
    var body: some View {
        HStack {
            VStack {
                Color.blue.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "blue", color: .blue)) // 🆕 
                Color.yellow.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "yellow", color: .yellow)) // 🆕 
                Color.red.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "red", color: .red)) // 🆕 
            }
            VStack {
                Text(colorName)
                    .frame(width: 100, height: 100)
                    .background {
                        Rectangle()
                            .fill(color)
                            .frame(width: 100, height: 100)
                            .border(.black)
                    }
            }
        }
    }
}

それぞれの色の四角を Drag するときに、それぞれに応じた NamedColor を渡しています。

独自型の Drop を受け取る

dropDestination を使用するところは、String の時と同じです。

struct ContentView: View {
    @State private var colorName: String = "NoColor"
    @State private var color: Color = .clear
    @State private var colorNameOnly: String = "NoColor"
    var body: some View {
        HStack {
            VStack {
                Color.blue.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "blue", color: .blue))
                Color.yellow.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "yellow", color: .yellow))
                Color.red.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "red", color: .red))
            }
            VStack {
                Text(colorName)
                    .frame(width: 100, height: 100)
                    .background {
                        Rectangle()
                            .fill(color)
                            .frame(width: 100, height: 100)
                            .border(.black)

                    }
                    .dropDestination(for: NamedColor.self) { items, location in   // 🆕 
                        guard let item = items.first else { return false }
                        color = item.color
                        colorName = item.name
                        return true
                    }
            }
        }
    }
}

for: の引数に受け取る型を指定して、closure では、受け取った NamedColor を color と colorName にセットしています。

実装した動作

以下のような動作になります。

複数の型に対応した Drag&Drop

NamedColor を Drag&Drop できるようにしましたが、NamedColor は、自分で作った型なので、受け取れるアプリは存在しません。受け取り側が、String だけであれば 受け取れるかもしれませんし、Color だけであれば 受け取れるかもしれません。Drag&Drop では、データを出す側は、さまざまなデータを用意しておくだけで、最終的にどのデータを受け取るかは、Drop される側の判断です。

Transferable 準拠にしていると、そのようなケースにも対応して、複数の形式で、Drag&Drop 対応させることができます。
# NSItemProvider でもできます。

String として Drag&Drop

まずは、文字列として、受け取るケースを実装します。

Drag 側は、同じです。NamedColor を渡します。
調整する箇所は、以下です。
・transferRepresentation の定義
・受け取る側(dropDestination)

transferRepresentation の定義を以下のように追加することで、String (name プロパティ)を渡すこと”も” できるようになります。

extension NamedColor: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: NamedColor.self, contentType: .namedColor)
        ProxyRepresentation(exporting: \.name)      // 🆕 
    }
}
注意

transferRepresentation に記載する順序は重要です。
上から順番にチェックされ、適合するデータが Drag&Drop に使用されます。

サンプルアプリには、受け取る側のView を追加しました。

String を受け取るので、Text を使った表示を変更しています。

struct ContentView: View {
    @State private var colorName: String = "NoColor"
    @State private var color: Color = .clear
    @State private var colorNameOnly: String = "NoColor"
    var body: some View {
        HStack {
            VStack {
                Color.blue.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "blue", color: .blue))
                Color.yellow.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "yellow", color: .yellow))
                Color.red.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "red", color: .red))
            }
            VStack {
                Text(colorName)
                    .frame(width: 100, height: 100)
                    .background {
                        Rectangle()
                            .fill(color)
                            .frame(width: 100, height: 100)
                            .border(.black)

                    }
                    .dropDestination(for: NamedColor.self) { items, location in
                        guard let item = items.first else { return false }
                        color = item.color
                        colorName = item.name
                        return true
                    }
                Text("dropped \(colorNameOnly) as String")        // 🆕 
                    .frame(width: 200, height: 50)
                    .border(.black)
                    .dropDestination(for: String.self) { items, location in
                        guard let item = items.first else { return false }
                        colorNameOnly = item
                        return true
                    }
            }
        }
    }
}

以下のような動作になります。

Color として Drag&Drop

最後に、Color だけを 受け取るケースを実装してみます。

Drag 側は、同じです。NamedColor を渡します。
調整する箇所は、以下です。
・transferRepresentation の定義
・受け取る側(dropDestination)

transferRepresentation の定義を以下のように追加することで、Color (color プロパティ)を渡すこと”も” できるようになります。

extension NamedColor: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: NamedColor.self, contentType: .namedColor)
        ProxyRepresentation(exporting: \.name)
        ProxyRepresentation(exporting: \.color)
    }
}

サンプルアプリには、受け取る側のView を追加しました。

Color を受け取るので、新しくColor 表示を追加して、受け取った Color で更新するようにしました。

struct ContentView: View {
    @State private var colorName: String = "NoColor"
    @State private var color: Color = .clear
    @State private var colorNameOnly: String = "NoColor"
    @State private var colorOnly: Color = .clear
    var body: some View {
        HStack {
            VStack {
                Color.blue.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "blue", color: .blue))
                Color.yellow.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "yellow", color: .yellow))
                Color.red.frame(width: 100, height: 100)
                    .draggable(NamedColor(name: "red", color: .red))
            }
            VStack {
                Text(colorName)
                    .frame(width: 100, height: 100)
                    .background {
                        Rectangle()
                            .fill(color)
                            .frame(width: 100, height: 100)
                            .border(.black)

                    }
                    .dropDestination(for: NamedColor.self) { items, location in
                        guard let item = items.first else { return false }
                        color = item.color
                        colorName = item.name
                        return true
                    }
                Text("dropped \(colorNameOnly) as String")
                    .frame(width: 200, height: 50)
                    .border(.black)
                    .dropDestination(for: String.self) { items, location in
                        guard let item = items.first else { return false }
                        colorNameOnly = item
                        return true
                    }
                colorOnly.frame(width: 100, height: 100).border(.black)
                    .dropDestination(for: Color.self) { items, location in
                        guard let item = items.first else { return false }
                        colorOnly = item
                        return true
                    }
            }
        }
    }
}

以下のような動作になります。

まとめ

Transferable を使った Drag & Drop を実装しました

Transferable を使った Drag & Drop の実装
  • 渡したい型を Transferable に準拠させる
  • static var transferRepresentation として、渡したい型を定義する
  • Codable に準拠させておくと簡単
  • UTType の登録は必要
  • 必要に応じて、複数のデータ型に対応しておくと便利

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です