[SwiftUI] onMove を自分で実装する

SwiftUI

     
⌛️ 2 min.
SwiftUI の List で onMove を安易に使うとハマるので、自作してみました

環境&対象

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

  • macOS Big Sur 11.3beta
  • Xcode 12.4
  • iOS 14.4

onMove の代替策

以下の記事でも説明していますが、SwiftUI の List/ForEach の onMove を使うと、気をつけないとすぐにクラッシュしてしまうことがあります。

SwiftUI [SwiftUI] List/ForEach の onMove についての メモ書き

だったら、自分で実装する方が良いのではないかということで、自分で実装してみたのですが、驚くほど簡単だったので、説明します。

# SwiftUI と Drag&Drop の仕組みの理解は必要です。

リスト表示のアプリ

まずは、ドラッグ&ドロップ対象となるアプリが必要です。

以下のように、テキストをリスト表示するアプリにしました。

List 表示アプリ


struct ContentView: View {
    @State var data = ["Item0", "Item1", "Item2", "Item3", "Item4"]
    var body: some View {
        List {
            ForEach(data, id: \.self) { element in
                Text(element)
                    .frame(height:20)
            }
            .padding()
        }
    }
}
List App
List App

onMove の分析

実装を開始する前に、onMove の機能を考えてみました。

onMove


func onMove(perform action: Optional(IndexSet, Int) -> Void>) -> some DynamicViewContent

リスト中の要素をドラッグした時に、要素間にインジケータを表示しドロップされた時には、onMove を呼び出します。引数の IndexSet がドラッグした要素のインデックス、Int が ドロップ先のインデックスになっています。

例題のケースで Item1 を Item3 の後にドロップすると、IndexSet には、1 が入っていて、Int には、4 が渡されてきます。

# この indexSet と int の組み合わせは配列等に適用できる move の引数と同じです。
# この move は、SwiftUI を import しないと使えません。

ですので、onMove は、リスト中の指定位置の要素を、別の指定位置に配置するための UI を作ってくれています。実際のデータ操作は、onMove の中に実装する必要があります。

少し分解すると、「指定位置の要素からのドラッグ開始」と「リスト要素間へのドロップ」を行ってくれるということです。

指定位置の要素からのドラッグ開始

つまり、要素からドラッグ開始ができるようにすれば良いということです。List/ForEach 中の要素に onDrag を使うことで、実現できます。

リスト要素間へのドロップ

これは、ForEach の onInsert を使うことで実現できます。

つまり、onMove = onDrag + onInsert ということです。

ドラッグ&ドロップによるリスト要素移動の実装

きちんと UTI を登録して、UTI による ドラッグ&ドロップとして実装していきます。

UTI 定義

以下のように定義しました。

UTType 定義


extension UTType {
    static var ddType: UTType {
        UTType(exportedAs: "com.smalldesksoftware.ddType", conformingTo: UTType.item)
    }
}

コード上での定義だけでなく、Info.plist にも登録します。

DragDropItem の定義

NSItemProvider で扱う オブジェクトを定義します。上で定義した UTI の読み書きに対応しているオブジェクトとして定義します。

MyDragDropData


final class MyDragDropData: NSObject, NSItemProviderReading, NSItemProviderWriting {
    static let dragDropID = UTType.ddType

    // data container
    var str: String
    init(_ str:String) {
        self.str = str
    }
    
    static var readableTypeIdentifiersForItemProvider: [String] { [ Self.dragDropID.identifier ] }
    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> MyDragDropData {
        guard typeIdentifier == Self.dragDropID.identifier else { throw MyError.generalError }
        if let str = String(data: data, encoding: .utf8) {
            return MyDragDropData(str)
        }
        throw MyError.generateError
    }

    static var writableTypeIdentifiersForItemProvider: [String] { [ Self.dragDropID.identifier ] }
    func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
        let data = str.data(using: .utf8)
        completionHandler(data, nil)
        return nil
    }
}

ドラッグ&ドロップは、NSItemProvider 経由でこのオブジェクトでのやりとりとなります。

実装していることは、プロパティの str の Data への変換とその逆です。

サンプルアプリの拡張

onMove = onDrag + onInsert だったので、Text に onDrag を付与し、ForEach に onInsert を指定していきます。

List 表示アプリ(ドラッグ&ドロップ対応)


struct ContentView: View {
    @State var data = ["Item0", "Item1", "Item2", "Item3", "Item4"]
    var body: some View {
        List {
            ForEach(data, id: \.self) { element in
                Text(element)
                    .frame(height:20)
                    .onDrag {
                        // (1)
                        NSItemProvider(object: MyDragDropData(element))
                    }
            }
            .onInsert(of: [MyDragDropData.dragDropID], perform: { index, providers in
                // (2)
                guard let provider = providers.first else { return }
                guard provider.canLoadObject(ofClass: MyDragDropData.self) else { return }
                provider.loadObject(ofClass: MyDragDropData.self) { (data, error) in
                    if let myDropData = data as? MyDragDropData {
                        // (3)
                        guard let sourceIndex = self.data.firstIndex(of: myDropData.str) else { return }
                        // (4)
                        self.data.move(fromOffsets: IndexSet(integer: sourceIndex), toOffset: index)
                    }
                }
            })
            .padding()
        }
    }
}
コード解説
  1. ドラッグ時に、ドラッグされる要素(String です) から MyDragDropData を作成し、NSItemProvider に渡します
  2. 指定した UTI 以外のドロップは受け付けないはずですが、念の為チェックしてます
  3. ドラッグ時に渡した MyDragDropData を復元し、データからどの要素がドラッグされたかを探します
  4. data (String の Array) の順序を入れ替えます
MEMO

MyDragDropData に ドラッグ元のインデックスを入れる方法もありますが、今回はデータからインデックスを検索してます。

実装したアプリの動き

機能的には onMove と同様に動きます。


# 2回目のドロップから ドロップインディケータが表示されなくなります。(SwiftUI の バグな気がします)

まとめ:SwiftUI の List/ForEach の onMove を自作する方法

SwiftUI の List/ForEach の onMove を自作する方法
  • onInsert, onDrag を使って、実装する
  • List 毎に異なる UTI を使用すると、List 間の ドラッグ&ドロップを制御しやすい
  • onInsert, onDrag を使うと、ドロップインディケータの動きが少し怪しい・・・(on macOS)

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

コメントを残す

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