[Swift] DropDelegate で画面外に放したアイテムを検知する

# はじめに

今回は `SwiftUI` の `DropDelegate` に関してです。
特定の挙動に対してあまり情報がなかったので、メモ的な感じで残しておきます。

# 概要

## DropDelegate に関して

`SwiftUI` 上でドラッグ＆ドロップを実装する場合、一番簡単なのは `List` の `editMode` を使うことです。

ただし、`List` では

- バージョンでの UI 差分（主に iOS15 未満で発生）
- `List` のセルによるデザイン制約
- 機能自体をカスタムができない
 
などの理由もあり、より細かい実装をしたい場合には、`DropDelegate` を使う状況もあると思います。

https://developer.apple.com/documentation/swiftui/dropdelegate

`DropDelegate` には、デフォルトで５つのメソッドが用意されています。

- `func validateDrop(info: DropInfo) -> Bool`
- `func performDrop(info: DropInfo) -> Bool`
- `func dropEntered(info: DropInfo)`
- `func dropUpdated(info: DropInfo) -> DropProposal?`： 
- `func dropExited(info: DropInfo)`

名前からもわかる通り、ドロップに関連したライフサイクル周りのメソッドです。


## DropDelegate で発生した問題

今回は `LazyVGrid` 上のアイテムを並べ替えさせる実装を想定しています。
以下のように、画面外にアイテムを持って行った場合で問題が起きました。

![ezgif-5-2723bd4a4f.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/47564c9b-d760-0571-db23-687b8b4b6e29.gif)

タイトルにもありますが、**長押しをした View を画面外に持っていき指を放したとき**、検知できずに困りました...。

あらかじめ用意された `func dropExited(info: DropInfo)`

> Tells the delegate a validated drop operation has exited the modified view.

が該当するように思ったのですが、アプリ上では反応したりしなかったり...🧐

<br>

少し試したところ

- iPad の SplitView
- Mac でのアプリ外

など、**アプリを跨ぐ場合**はちゃんと反応してくれそうな感じでした。

以下は例として、`Swift Playgrounds` 上でアプリを跨いだ場合の反応テストをした結果です。

![Screenshot 2024-01-08 at 1.35.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/b4a23f9b-3258-e1e4-fb2d-d49fc40c488a.png)

このように、アプリを跨いだ場合は `dropExited` がちゃんとよばれています。

## 放したアイテムを検知する方法

いくつかのケースで動作しないパターンがあるようなので、今回は３つご紹介します。

### - 移動するアイテムの Opacity を付ける

これは移動するアイテムを透過している場合にのみの起こります。
例えば、移動中のみアイテムを透過にしたい場合があると思います。

【Example】

```.swift
ForEach(models) { model in
    GridItem()
        .opacity(draggingModel?.id == model.id ? 0 : 1)
        .onDrag {
            /* 略 */
        }
        .onDrop(
            /* 略 */
        )
}
```

このように移動するアイテムの `opacity` が `0` だと、破棄が検知されません。
（`performDrop` が呼ばれない）

対策としては

- （可能なら）背景色をつける
- `opacity` を 0.001 でうっすらつける

などの対策が有効です。

### - NSItemProvider のライフサイクルを利用する

この方法は **※ iOS15.4以上 ※** でしか使えません。

`DropDelegate` は `.onDrag` で返される `NSItemProvider` を元に差分を検知しています。

【Example】

```.swift
ForEach(models) { model in
    GridItem()
        .onDrag {
            let identifier = String(model.id) as NSString
            return NSItemProvider(object: identifier)
        }
        .onDrop(
            /* 略 */
        )
}
```

この `NSItemProvider` はドラッグが終わると破棄されるので、これを利用します。具体的には、独自に `NSItemProvider` を作成して、**破棄されるタイミングで処理を差し込む**ようにします。

独自の `NSItemProvider` を作成し

```.swift
class GridItemProvider: NSItemProvider {

    var deinitialize: (() -> Void)?

    deinit {
        deinitialize?()
    }
}
```

`.onDrag` で処理をいれる

```diff_swift
ForEach(models) { model in
    GridItem()
        .onDrag {
            let identifier = String(model.id) as NSString
-           return NSItemProvider(object: identifier)
+           let provider = GridItemProvider(object: identifier)
+           provider.deinitialize = {
+               DispatchQueue.main.async {
+                   // do some handling
+               }
+           }
+           return provider
        }
        .onDrop(
            /* 略 */
        )
}
```

処理がバックグラウンドなので、必ず**メインスレッドで動く**ようにしましょう。
(`MainActor` でも大丈夫だが扱いに注意)

以下は実際の動作例です。

![ezgif-5-53e955fc55.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/12cd1944-b21b-70ce-6dab-c422529f01e3.gif)

画面外で離した場合に、`NSItemProvider` が破棄されてハンドリングできます！

冒頭に記載しましたが、`iOS15.4 以上`でないと `denit` のタイミングが違うため動作しないことに注意しましょう。

### - 外側の View にも DropDelegate を付ける

今回実装しているものは、アイテムが横に全画面だったため対象外でした。
こちらは画面の一部にあるアイテムを移動する場合に該当します。

実際の例を見てもらう方が早いでしょう。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/2e283e6b-973a-914f-02cc-98423329ade1.gif" width=500>

このように領域外に持って行った場合、アイテム廃棄が検知されずドロップ状態が続いています。

この場合は、外側の `View` にも `DropDelegate` をつけてあげることで、検知できるようになります。

【Example】

```.swift
var body: some View {
    ScrollView {
        LazyVGrid(columns: columns) {
            ForEach(models) { model in
                GridItem()
                    .overlay(dragging?.id == d.id ? Color.white.opacity(0.8) : Color.clear)
                    .onDrag {
                        /* 略 */
                    }
                    .onDrop(of: [UTType.text], delegate: YourDragDelegate(
                        /* 略 */
                    ))
            }
        }
    }
    .onDrop(of: [UTType.text], delegate: YourDropOutsideDelegate(
        /* 略 */
    ))
}
```

実際の動作はこうなります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/2392e8bc-9561-1546-f0ca-67ed57502848.gif" width=500>

ちゃんとハンドリングされて、色も戻っています！

## 動かさずに放したアイテムに対処する

※ **以下はiOS17未満で発生します。**※

ここまで実装しても、もう１つハンドリングできない動作があります。


具体的には、`onDrag` で**長押しをして動かさずに戻した場合**です。この場合は `NSItemProvider` の破棄が呼ばれないため検知できません。

以下は実際の動作です。

![Screen-Recording-2024-01-09-at-13.50.37.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/2af4268f-f43a-6d31-b3a3-1a01b5523bdd.gif)
（もう一度見たい場合はクリック）

動作では、次のドラッグ動作が開始されると、１つ前の `NSItemProvider` が破棄されています。

これはコントロールできないため `Timer` を使って擬似的にキャンセルする方法を考えました。（もし他の方法があればコメントをくださいmm）

### 実装コード例

コード全てを載せるのは大変なので、ざっくり実装したものを置いておきます。

`DropDelegate` を使う場合、ドラッグしているアイテムを自前でコントロールすると思いますが、それをタイマーを使ってリセットします。具体的には、1-2秒間アイテムの移動がなかった場合は、破棄されたとみなしてキャンセルするようにします。

今回はタイマーを`View`のモデルで管理する想定です。


```ViewModel.swift
class ViewModel: ObservableObject {
    /* 略 */

    var draggingTimer: Timer?

    /* 略 */

    func setTimer() {
        draggingTimer = .scheduledTimer(
            withTimeInterval: 1.5,
            repeats: true
        ) { [weak self] _ in
            // ドラッグアイテムのリセット
        }
    }

    func invalidateTimer() {
        draggingTimer?.invalidate()
    }
}
```

`DropDelegate` の処理で `Timer` の`セット`・`リセット`を行います。
タイマーをモデルで管理するため、`DropDelegate` では処理を委譲します。

```.swift
struct YourDropDelegate: DropDelegate {
    var dropEntered: () -> Void
    var dropUpdated: () -> DropProposal?
    var performDrop: () -> Bool
    func dropEntered(info: DropInfo) { dropEntered() }
    func dropUpdated(info: DropInfo) -> DropProposal? { dropUpdated() }
    func performDrop(info: DropInfo) -> Bool { performDrop() }
}
```

`セット`・`リセット`の処理はこのように行います。

```.swift
@ObservedObject var viewModel: ViewModel

/* 略 */

GridRow(model: model)
    .onDrag { /* タイマーのセット */ }
    .onDrop(of: [UTType.text], delegate: YourDropDelegate(
        dropEntered: { /* タイマーのリセット */ },
        dropUpdated: { /* Do nothing */ },
        performDrop: { /* タイマーのリセット */ }
    ))
```

実際に動作するものを置いておくので、こちらでお試しくださいmm

https://github.com/crane-hiromu/DragAndDrop


# 終わりに

`SwiftUI` の `DropDelegate` は不完全な部分も多いです。正直なところカスタムはしずらいので、`UIKit` をラップするほうが、現実的かもしれません。

毎年アップデートされ、動作は改善されているので、今後に期待ですね！

## 参考

https://stackoverflow.com/questions/72378327/swiftui-execute-performdrop-on-dropentered

https://stackoverflow.com/questions/62606907/swiftui-using-ondrag-and-ondrop-to-reorder-items-within-one-single-lazygrid