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

SwiftUI

SwiftUI の List/ForEach の onMove についてのメモ。

環境&対象

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

  • macOS Big Sur 11.2.3
  • Xcode 12.4
注意
以下は、macOS 11.2.3 での振る舞いを調べた結果であり、恒久的に成り立つものではないと思われます。

調査のきっかけ

macOS で複数の List を画面上に出してそれぞれの List を D&D で順序を入れ替えられるようにしました。(i.e. 画面上に同時に現れる2つの List に onMove を設定しました)

この時に、ふと、一方から他方のリストにも D&D してみると、なんと D&D できてしまい、さらにクラッシュしてしまうことに気づきました。

調査の発端は、このクラッシュをどのように防ぐかの調査でした。

おそらくこの状況は、macOS 特有の状況です。iOS 端末では、List が画面上に複数表示されることはあまり多くないと考えられるからです。

ちなみに、以下の調査は、ドキュメントではなく、実際の動作を確認して、考察したものです。

Apple のドキュメントは言うまでもなく、Web 上の記事等でも SwiftUI の情報は、圧倒的に、iOS 向けに提供されており、macOS 向けのものがほとんどありません。

macOS 向けに使おうとしている人の参考になればということで書いてます。

# iOS での振る舞いはチェックしてませんが、おそらくあまり変わらないとも予想します。

以下、「List に onMove を付与して」的な文章にしていますが、もちろん modifier の対象は、List ではなく ForEach です。

.onMove 指定で動作開始する onDrop/onDrag について

.onMove を指定すると、暗黙的に onDrag と onDrop が指定されるハズです。(でないと、ドラッグの起点と終点になれません。)

ドロップできるか/ドラッグできるかを 積極的に制御することで、クラッシュを防ぎたいと考えたので、その暗黙的に指定されているであろう onDrag と onDrop について少し調査&考察しました。

onDrag について

まずは、どんなデータがドラッグされているかについて調べました。

最初にドキュメントをいろいろと読んだのですが、さっぱり書いてありません。

実際の動作から調べることにしてコードを動かしてみました。

onMove を設定したリストから、onInsert を付与した別のリストに、ドロップすることで どんなタイプのどんなデータを provide しているか調査しました。

まず タイプですが、onDrag で提供するタイプは、"com.apple.SwiftUI.listReorder" でした。特に、ImportedAs や ExportedAs として Info.plist に登録していませんが、使われているようです。Apple が SwiftUI 内部で定義して公開しているかとも期待したのですが、そのような定義も見当たりませんでした。

諦めて(?)、データの中身を解読しようとしました。とりあえず、String に decode できるか試してみると、なんと、decode できました。

実際に、リストの一番上の要素を Drag して、リストの1番目と2番目の間に Drop した時の NSItemProvider が提供してくれる中身を String に decode してみました。
得られた値は {"indexes":[0]} です。NSIndexSet の JSON版 っぽいです。 0 は、ドラッグ元のアイテムの Index に見えます。

動作確認で使用したコードは、以下の通りです。Drop を受ける側のリストに設定しています。

example

.onInsert(of: ["no-acceptance"]) { (index, providers) in
    for provider in providers {
        provider.loadItem(forTypeIdentifier: "com.apple.SwiftUI.listReorder", options: nil) { (data, error) in
            if let str = String(data: data as! Data, encoding: .utf8) {
                print(str)
            }
        }
    }
}

上記のコードを実行したところ、{"indexes":[0]} と表示されました。

# ちなみに、ここで 意味不明の文字列になっていたら、その時点で調査は終了してました。

MEMO
面白いことに、onInsert に 意味不明なタイプを指定しているにも関わらず、.onMove とセットで設定されていると .onMove で生成される onDrag からのアイテムを onInsert でも受け付けることができてしまいます。

この理由はあとからわかってきます。

onMove に渡せる onDrag は?

次に、Drag される側について少し調べてみした。

予想としては、"com.apple.SwiftUI.listReorder" というタイプで、さらに値としては、{"indexes":[1]} のような String を Data 化したもの です。
# 互換オブジェクトを探すのが目的ではなく、onMove が受け取るタイプを限定するための確認です。

以下のように定義した オブジェクトを onMove は受け取りました。(別途確認しましたが、別の UTI/identifier を持つものは、onMove は受け付けません)

受け渡しに使うクラス

SwiftUIListReorder

final class SwiftUIListReorder: NSObject, NSItemProviderReading, NSItemProviderWriting, Codable {
    static var readableTypeIdentifiersForItemProvider: [String] = ["com.apple.SwiftUI.listReorder"]
    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> SwiftUIListReorder {
        let decoder = JSONDecoder()
        let reorderData = try decoder.decode(SwiftUIListReorder.self, from: data)
        return reorderData
    }
    
    static var writableTypeIdentifiersForItemProvider: [String] = ["com.apple.SwiftUI.listReorder"]
    func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
        let encoder = JSONEncoder()
        do {
            let json = try encoder.encode(self)
            completionHandler(json, nil)
        } catch {
            print(error.localizedDescription)
            completionHandler(nil, error)
        }
        return nil
    }
    
    var indexes:[Int]
    init(_ values:[Int]) {
        self.indexes = values
    }
}

# ここでは、文字列のみ使用していますが、自分で UTType を定義すると、Info.plist にも登録しないといけません。登録しないとXcode が未登録であるとしてワーニングを出してきます。

上記のクラスを以下のように Drag 時に渡すようにすると、onMove を設定した別のリストにドロップすることができるようになります。

以下のようなコードで .onDrag で指定して試行しています。

List with onDrag

List {
    ForEach(texts.indices) { index in
        Text(texts[index])
            .onDrag { NSItemProvider(object: SwiftUIListReorder([index]))}
    }
}

ドロップ先の List は、以下のように作りました。

DropTargetList

List {
    ForEach(0..<3) { index in
        Text(String(index))
    }
    .onMove { (indexSet, dest) in
        print("onMove is called")
    }
    .onInsert(of: [UTType.SwiftUIReorderData]) { (index, providers) in
        print("onInsert called")
    }
}

この時、ドロップ先の List に onInsert が定義されていないと 以下のエラーで crash します。

発生するエラー
Fatal error: Attempting to insert row (destination: 1) with no associated insert action.: file SwiftUI, line 0

# destination: 1 は、ドロップ先のインデックスを表しているようなので、ドロップ位置で都度変わります。

つまり、何らかの判断基準で onMove は受け取ってはいるものの、自分で処理できない/すべきでないと判断し、onInsert に処理を委譲しているようです。その時に onInsert が定義されていないとクラッシュします。

見方を変えると、1つのリストで、onMove を設定すると、外部からのドロップをとりあえず受け取るという 振る舞いをするようになってしまいます。

問題点/クラッシュの原因

一番初めの、onMove をつけた 2つの List 間での D&D がクラッシュを引き起こす理由は、端的には、「drop 先の List で onInsert が定義されていないから」ということのようです。

少し長く描くと、
「drop 先の onMove が一旦受け取り、自分で処理すべきでないと判断し、onInsert に委譲しようとするが、onInsert がないので、クラッシュ」
ということのようです。

最初に、onInsert で指定した UTI ではないのに、onInsert が呼ばれている理由は、onMove 経由で呼ばれているからと推測できます。(あくまで推測ですが)

実際に、ドロップ先の List に onInsert を定義すると、クラッシュしなくなります。(先のテストコードはクラッシュを防ぐために、onInsert を入れていました)

.onMove では、 受け付けるリストはおろか ドロップされるアイテムのタイプも指定できないので、単純に 引数等を指定することで対応できる問題ではない気がします。

つまり SwiftUI の List を macOS で使う時には、気をつけないと簡単にクラッシュするということです。

自アプリの中で1つのリストしか使っていないからといっても onMove がついている List であれば、(SwiftUI の List を持つ)外部アプリから Drop されるだけで、クラッシュしてしまいます。

解決策

Quick Hack 的な解決策

解決策としては、空の .onInsert を定義しておくことでクラッシュしなくなります。

D&D のタイプとして既に macOS が認識しているタイプなので、ドロップ先としてのプレビューが出ることは止められません。ちょっとカッコ悪いです・・・

SwiftUI の List の onMove は、プロトタイプ目的では非常に有用ですが、AppStore で公開するようなアプリに使うのは難しい気がしてきました。

地道で包括的な解決策

onMove が受け付けるリストを指定できない以上 onMove で受け付けないタイプを onDrag で提供するしかありません。

そうすると、自リスト内の D&D も onMove で受け付けられなくなります。

つまり、onMove 含め 自分で作るということです・・・・

対象としたいリスト毎に 異なる identifier を持つ Item を作成することで、異なるリスト間の D&D を禁止することができます。(ドロップのプレビュー表示をなくすことができます)

WWDC2021 で改善されそうな気がしますが、当面は、防御的に作り込むしかない気がします。

onDelete について

なお、onDelete は UI の動作から自リスト内の要素しか対象になり得ないので、問題にはなりません。

まとめ:SwiftUI での 複数 List 間での onMove の扱いについての考察

SwiftUI での 複数 List 間での onMove の扱いについての考察
  • onMove を指定すると "com.apple.SwiftUI.listReorder" という ID で Drag&Drop される
  • "com.apple.SwiftUI.listReorder" という ID は、SwiftUI の 全ての List の onMove で共通
  • onMove は、自リストだけでなく、外部アプリの SwiftUI の List の onMove からも受け付けてしまう
  • onMove が受け付けないようにする方法は、onMove 相当を、UTI を指定できる onDrop で作る(しかない・・・)

自分で書いていて 悲観的すぎる気がします。情報お持ちの方、教えていただけるとありがたいです。

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

コメントを残す

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