LoginSignup
10
6

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

Last updated at Posted at 2024-01-08

はじめに

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

概要

DropDelegate に関して

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

ただし、List では

  • バージョンでの UI 差分(主に iOS15 未満で発生)
  • List のセルによるデザイン制約
  • 機能自体をカスタムができない

などの理由もあり、より細かい実装をしたい場合には、DropDelegate を使う状況もあると思います。

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

  • 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

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

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

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

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


少し試したところ

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

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

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

Screenshot 2024-01-08 at 1.35.28.png

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

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

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

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

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

【Example】

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

このように移動するアイテムの opacity0 だと、破棄が検知されません。
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 で処理をいれる

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

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

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

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

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

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

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

この場合は、外側の 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(
        /* 略 */
    ))
}

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

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

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

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

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

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

以下は実際の動作です。

Screen-Recording-2024-01-09-at-13.50.37.gif
(もう一度見たい場合はクリック)

動作では、次のドラッグ動作が開始されると、1つ前の 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

終わりに

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

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

参考

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6