はじめに
今回は SwiftUI
の DropDelegate
に関してです。
特定の挙動に対してあまり情報がなかったので、メモ的な感じで残しておきます。
概要
DropDelegate に関して
SwiftUI
上でドラッグ&ドロップを実装する場合、一番簡単なのは List
の editMode
を使うことです。
ただし、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
上のアイテムを並べ替えさせる実装を想定しています。
以下のように、画面外にアイテムを持って行った場合で問題が起きました。
タイトルにもありますが、長押しをした View を画面外に持っていき指を放したとき、検知できずに困りました...。
あらかじめ用意された func dropExited(info: DropInfo)
Tells the delegate a validated drop operation has exited the modified view.
が該当するように思ったのですが、アプリ上では反応したりしなかったり...🧐
少し試したところ
- iPad の SplitView
- Mac でのアプリ外
など、アプリを跨ぐ場合はちゃんと反応してくれそうな感じでした。
以下は例として、Swift Playgrounds
上でアプリを跨いだ場合の反応テストをした結果です。
このように、アプリを跨いだ場合は dropExited
がちゃんとよばれています。
放したアイテムを検知する方法
いくつかのケースで動作しないパターンがあるようなので、今回は3つご紹介します。
- 移動するアイテムの Opacity を付ける
これは移動するアイテムを透過している場合にのみの起こります。
例えば、移動中のみアイテムを透過にしたい場合があると思います。
【Example】
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】
ForEach(models) { model in
GridItem()
.onDrag {
let identifier = String(model.id) as NSString
return NSItemProvider(object: identifier)
}
.onDrop(
/* 略 */
)
}
この NSItemProvider
はドラッグが終わると破棄されるので、これを利用します。具体的には、独自に NSItemProvider
を作成して、破棄されるタイミングで処理を差し込むようにします。
独自の NSItemProvider
を作成し
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
でも大丈夫だが扱いに注意)
以下は実際の動作例です。
画面外で離した場合に、NSItemProvider
が破棄されてハンドリングできます!
冒頭に記載しましたが、iOS15.4 以上
でないと denit
のタイミングが違うため動作しないことに注意しましょう。
- 外側の View にも DropDelegate を付ける
今回実装しているものは、アイテムが横に全画面だったため対象外でした。
こちらは画面の一部にあるアイテムを移動する場合に該当します。
実際の例を見てもらう方が早いでしょう。
このように領域外に持って行った場合、アイテム廃棄が検知されずドロップ状態が続いています。
この場合は、外側の View
にも DropDelegate
をつけてあげることで、検知できるようになります。
【Example】
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
の破棄が呼ばれないため検知できません。
以下は実際の動作です。
動作では、次のドラッグ動作が開始されると、1つ前の NSItemProvider
が破棄されています。
これはコントロールできないため Timer
を使って擬似的にキャンセルする方法を考えました。(もし他の方法があればコメントをくださいmm)
実装コード例
コード全てを載せるのは大変なので、ざっくり実装したものを置いておきます。
DropDelegate
を使う場合、ドラッグしているアイテムを自前でコントロールすると思いますが、それをタイマーを使ってリセットします。具体的には、1-2秒間アイテムの移動がなかった場合は、破棄されたとみなしてキャンセルするようにします。
今回はタイマーをView
のモデルで管理する想定です。
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
では処理を委譲します。
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() }
}
セット
・リセット
の処理はこのように行います。
@ObservedObject var viewModel: ViewModel
/* 略 */
GridRow(model: model)
.onDrag { /* タイマーのセット */ }
.onDrop(of: [UTType.text], delegate: YourDropDelegate(
dropEntered: { /* タイマーのリセット */ },
dropUpdated: { /* Do nothing */ },
performDrop: { /* タイマーのリセット */ }
))
実際に動作するものを置いておくので、こちらでお試しくださいmm
終わりに
SwiftUI
の DropDelegate
は不完全な部分も多いです。正直なところカスタムはしずらいので、UIKit
をラップするほうが、現実的かもしれません。
毎年アップデートされ、動作は改善されているので、今後に期待ですね!
参考