DnDで順序を入れ替える

# はじめに
SwiftUIで、iPhoneのホーム画面のAppのように、グリッドの要素をドラッグ＆ドロップで並び替える方法を探していました。
しかし、出てくるものはListの入れ替えばかり...英語の記事の物を試しても動作がイマイチでした。
関連する日本語の記事もないようなので自分で作ることにしました。

## 動作デモ
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/953829/6b69779b-3337-bd58-242d-2cad943779c7.gif)

## 環境
MacBook Air (M1, 2020)
macOS Big Sur 11.5.2（20G95）
Xcode Version 12.5.1 (12E507)
Swift version 5.4.2
Simulator Version 12.5.1 (961.1), iOS 14.5

# コード解説
下に全体のソースがあります。

## データを表すためのプロトコル

``` swift
protocol ReorderableElement: Identifiable, Equatable {
    var id: UUID { get }
}

protocol ReorderableData: ObservableObject {
    associatedtype Element: ReorderableElement
    var array: [Element] { get set }
    var draggingId: UUID { get set }
}
```

`ReorderableElement`は各要素を表す`struct`や`class`に準拠させます。`ForEach`でアニメーションさせるために`Identifiable`と`Equatable`を継承しています。
`ReorderableElement.id`は各要素を識別するIDで、`UUID()`で初期化しなければいけません。

`ReorderableData`は入れ替えたい要素をまとめる`class`に準拠させます。`@Publiched`で監視するために`ObservableObject`を継承しています。
`ReorderableData.array`は要素の配列です。`@Publiched`をつけて宣言してください。
`ReorderableData.draggingId`はドラッグ中の要素が持つ`id`で、`UUID()`で初期化しなければいけません。`@Publiched`をつけて宣言してください。



## ドラッグ＆ドロップのイベントを処理する`UIView`

``` swift
class ReorderingDnDHost: UIView {
    var onSnapshot = { UIImage() }
    
    private let onDragBegun: () -> Void
    private let onDragEnded: () -> Void
    private let onDropEntered: () -> Void

    private var previewView = UIImageView()
    private let previewParameters = UIDragPreviewParameters()
    private lazy var previewTarget = UIDragPreviewTarget(container: self, center: self.center)

    init(onDragBegun: @escaping () -> Void, onDragEnded: @escaping () -> Void, onDropEntered: @escaping () -> Void) {
        self.onDragBegun = onDragBegun
        self.onDragEnded = onDragEnded
        self.onDropEntered = onDropEntered

        super.init(frame: .zero)

        self.backgroundColor = .clear
        self.isUserInteractionEnabled = true

        let dragInteraction = UIDragInteraction(delegate: self)
        dragInteraction.isEnabled = true
        self.addInteraction(dragInteraction)

        let dropInteraction = UIDropInteraction(delegate: self)
        self.addInteraction(dropInteraction)

        previewParameters.backgroundColor = .clear
        previewParameters.shadowPath = UIBezierPath()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
```

`onSnapshot`はPreviewに表示する`View`のスナップショットを取得するためのクロージャです。
`onDragBegun`はドラッグ開始時に呼ばれるクロージャです。
`onDragEnded`はドラッグ終了時に呼ばれるクロージャです。
`onDropEntered`はドラッグがこの`UIView`の領域に入った時に呼ばれるクロージャです。

`previewView`はPreviewで表示する`UIImageView`です。
`previewParameters`はPreviewに関するパラメータセットです。
`previewTarget`はドラッグがキャンセルされた時にPreviewが戻る先です。

`UIDragInteraction.isEnabled`はiPhoneではデフォルトで`false`になっているらしいです。

`UIDragPreviewParameters.shadowPath`に`UIBezierPath()`を代入するとPreviewの影が消えます（nilは代入しても変化しませんでした）。スナップショットの形が矩形以外の時、不自然になるので消しています。

`required`から始まる`init`は、`UIView`や`UIViewController`を継承した`class`で独自の`init`を定義する場合に書かないといけないものらしいです。

## ドラッグのイベントを受け取るためのDelegateの拡張

``` swift
extension ReorderingDnDHost: UIDragInteractionDelegate {
    func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] {
        previewView = UIImageView(image: onSnapshot())
        previewView.layer.shadowColor = UIColor.lightGray.cgColor
        previewView.layer.shadowRadius = 5
        
        return [UIDragItem(itemProvider: NSItemProvider())]
    }

    func dragInteraction(_ interaction: UIDragInteraction, previewForLifting item: UIDragItem, session: UIDragSession) -> UITargetedDragPreview? {
        previewView.layer.shadowOpacity = 1

        return UITargetedDragPreview(view: previewView, parameters: previewParameters, target: previewTarget)
    }

    func dragInteraction(_ interaction: UIDragInteraction, sessionAllowsMoveOperation session: UIDragSession) -> Bool {
        onDragBegun()
        return true
    }

    func dragInteraction(_ interaction: UIDragInteraction, previewForCancelling item: UIDragItem, withDefault defaultPreview: UITargetedDragPreview) -> UITargetedDragPreview? {
        previewView.layer.shadowOpacity = 0

        return UITargetedDragPreview(view: previewView, parameters: previewParameters, target: previewTarget)
    }

    func dragInteraction(_ interaction: UIDragInteraction, session: UIDragSession, didEndWith operation: UIDropOperation) {
        onDragEnded()
    }
}
```

メソッドの名前ではなく、外部引数名で呼ぶメソッドを識別しているようです。Swiftらしいですね。

`itemsForBeginning`のメソッドは、この`UIView`が長押しされた後に呼ばれます。このメソッドの実装は必須で、その他のメソッドは必須ではありません。空の配列ではなく、何かしらを返さないとドラッグが開始されません。

`previewForLifting`のメソッドは、`itemsForBeginning`のメソッドが呼ばれた後、Previewを作るために呼ばれます。このメソッドを実装しないと、この`UIView`の見た目がそのままPreviewになり、ドラッグ終了時のアニメーションが大きくなってフェードアウトになります。

`sessionAllowsMoveOperation`のメソッドは、この`UIView`が長押しされ、ドラッグされ始めた時に呼ばれます。`onDragBegun`を`itemsForBeginning`のメソッドなどではなく、このメソッドで呼んでいる理由は、ドラッグされずに長押しされたままの場合にセッションがタイムアウトし、他のメソッドが呼ばれないまま終了するからです。

`previewForCancelling`のメソッドは、ドロップを受け付けない領域でドロップされ、そのPreviewを作る時に呼ばれます。`previewForLifting`のメソッドではPreviewに影をつけましたが、このメソッドではつけていません。それはセッションが終了した時、アニメーションせずにPreviewが消え、影が突然消えて不自然だからです。

`didEndWith`のメソッドは、ドラッグが終了して最後に呼ばれます。`onDragEnded`を呼んでいます。

## ドロップのイベントを受け取るためのDelegateの拡張

``` swift
extension ReorderingDnDHost: UIDropInteractionDelegate {
    func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnter session: UIDropSession) {
        onDropEntered()
    }

    func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal {
        return UIDropProposal(operation: .cancel)
    }
}
```

`UIDropInteractionDelegate`には実装が必須のメソッドはありません。

`sessionDidEnter`のメソッドは、この`UIView`の領域にドラッグが入ってきた時に呼ばれます。`sessionDidEnter`を呼んでいます。

`sessionDidUpdate`のメソッドは、この`UIView`の領域内でドラッグされている時、動くたびに呼ばれます。`UIDropProposal`を返すことで、この`UIView`に対するドロップの操作を指定することができます。ドロップさせる必要はないので`.cancel`でドロップできないことを表しています。

## `ReorderingDnDHost`のラッパ

``` swift
struct ReorderingHost<Data: ReorderableData>: UIViewRepresentable {
    typealias UIViewType = ReorderingDnDHost

    let data: Data
    let elementId: UUID
    let onSnapshot: () -> UIImage

    func makeUIView(context: Context) -> ReorderingDnDHost {
        ReorderingDnDHost(onDragBegun: {
            data.draggingId = elementId
        }, onDragEnded: {
            data.draggingId = UUID()
        }, onDropEntered: {
            guard data.draggingId != elementId else {
                return
            }

            let _from = data.array.firstIndex {
                $0.id == data.draggingId
            }
            let _to = data.array.firstIndex {
                $0.id == elementId
            }

            guard let from = _from, let to = _to else {
                return
            }

            withAnimation {
                data.array.move(fromOffsets: IndexSet(integer: from), toOffset: to + (from < to ? 1 : 0))
            }
        })
    }

    func updateUIView(_ uiView: ReorderingDnDHost, context: Context) {
        uiView.onSnapshot = onSnapshot
    }
}
```

`UIView`を継承している`ReorderingDnDHost`をラップし、`View`に変換すると同時に要素の入れ替えの処理も行います。

`elementId`はこの`View`が紐づけられている要素の`id`です。
`onSnapshot`はPreviewに表示する`UIImage`を取得する時に呼ばれるクロージャです。

ドラッグが開始された時、`data.draggingId = elementId`でこの`View`に紐づけられた要素がドラッグされていることを表します。

ドラッグが終了した時、`data.draggingId = UUID()`でドラッグされている要素がないことを表します。

ドラッグがこの`View`の領域に入った時、`withAnimation`のブロックで要素の並び替えをします。

## `View`の拡張で標準のModifierのように簡単に呼べるようにする

``` swift
extension View {
    func snapshot() -> UIImage {
        let _view = UIHostingController(rootView: self).view

        guard let view = _view else {
            return UIImage()
        }

        let targetSize = view.intrinsicContentSize
        view.bounds = CGRect(origin: .zero, size: targetSize)
        view.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)

        return renderer.image { _ in
            view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
        }
    }

    func reorderable<Data: ReorderableData>(data: Data, elementId: UUID) -> some View {
        self
            .opacity(data.draggingId == elementId ? 0 : 1)
            .overlay(
                ReorderingHost(data: data, elementId: elementId) {
                    self
                        .padding(10)
                        .snapshot()
                }
            )
    }
}
```

`snapshot()`は`View`のスナップショットを`UIImage`として取得します。

`reorderable(data:elementId:)`は`View`を入れ替え可能な要素にします。`data`には`ReorderableData`を、`elementId`には要素が持つ`id`を渡してください。

`opacity(_:)`で`View`がドラッグされている間は透明にします。
スナップショットを撮る前に`.padding(10)`で余白を作っている理由は、元の`View`に余白がなかった場合にPreviewの影がクリップされてしまうからです。

## 実装例

``` swift
struct ColorElement: ReorderableElement {
    let id = UUID()
    let color: Color
}

class Palette: ReorderableData {
    @Published var array: [ColorElement]
    @Published var draggingId = UUID()

    init(colors: [Color]) {
        self.array = colors.map {
            ColorElement(color: $0)
        }
    }
}

struct ReorderingElements: View {
    @ObservedObject var palette: Palette

    var body: some View {
        ScrollView {
            LazyVGrid(columns: Array(repeating: GridItem(.fixed(80)), count: 4), spacing: nil) {
                ForEach(palette.array) { colorData in
                    Circle()
                        .fill(colorData.color)
                        .frame(width: 50, height: 50)
                        .padding()
                        .reorderable(data: palette, elementId: colorData.id)
                }
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        ReorderingElements(palette: Palette(colors: (0..<100).map {
            Color(hue: Double($0) / 100, saturation: 1, brightness: 1)
        }))
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

## 全体のソース

``` swift
import SwiftUI

// MARK: - テンプレートコード

protocol ReorderableElement: Identifiable, Equatable {
    var id: UUID { get }
}

protocol ReorderableData: ObservableObject {
    associatedtype Element: ReorderableElement
    var array: [Element] { get set }
    var draggingId: UUID { get set }
}

class ReorderingDnDHost: UIView {
    var onSnapshot = { UIImage() }
    
    private let onDragBegun: () -> Void
    private let onDragEnded: () -> Void
    private let onDropEntered: () -> Void

    private var previewView = UIImageView()
    private let previewParameters = UIDragPreviewParameters()
    private lazy var previewTarget = UIDragPreviewTarget(container: self, center: self.center)

    init(onDragBegun: @escaping () -> Void, onDragEnded: @escaping () -> Void, onDropEntered: @escaping () -> Void) {
        self.onDragBegun = onDragBegun
        self.onDragEnded = onDragEnded
        self.onDropEntered = onDropEntered

        super.init(frame: .zero)

        self.backgroundColor = .clear
        self.isUserInteractionEnabled = true

        let dragInteraction = UIDragInteraction(delegate: self)
        dragInteraction.isEnabled = true
        self.addInteraction(dragInteraction)

        let dropInteraction = UIDropInteraction(delegate: self)
        self.addInteraction(dropInteraction)

        previewParameters.backgroundColor = .clear
        previewParameters.shadowPath = UIBezierPath()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension ReorderingDnDHost: UIDragInteractionDelegate {
    func dragInteraction(_ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession) -> [UIDragItem] {
        previewView = UIImageView(image: onSnapshot())
        previewView.layer.shadowColor = UIColor.lightGray.cgColor
        previewView.layer.shadowRadius = 5
        
        return [UIDragItem(itemProvider: NSItemProvider())]
    }

    func dragInteraction(_ interaction: UIDragInteraction, previewForLifting item: UIDragItem, session: UIDragSession) -> UITargetedDragPreview? {
        previewView.layer.shadowOpacity = 1

        return UITargetedDragPreview(view: previewView, parameters: previewParameters, target: previewTarget)
    }

    func dragInteraction(_ interaction: UIDragInteraction, sessionAllowsMoveOperation session: UIDragSession) -> Bool {
        onDragBegun()
        return true
    }

    func dragInteraction(_ interaction: UIDragInteraction, previewForCancelling item: UIDragItem, withDefault defaultPreview: UITargetedDragPreview) -> UITargetedDragPreview? {
        previewView.layer.shadowOpacity = 0

        return UITargetedDragPreview(view: previewView, parameters: previewParameters, target: previewTarget)
    }

    func dragInteraction(_ interaction: UIDragInteraction, session: UIDragSession, didEndWith operation: UIDropOperation) {
        onDragEnded()
    }
}

extension ReorderingDnDHost: UIDropInteractionDelegate {
    func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnter session: UIDropSession) {
        onDropEntered()
    }

    func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal {
        return UIDropProposal(operation: .cancel)
    }
}

struct ReorderingHost<Data: ReorderableData>: UIViewRepresentable {
    typealias UIViewType = ReorderingDnDHost

    let data: Data
    let elementId: UUID
    let onSnapshot: () -> UIImage

    func makeUIView(context: Context) -> ReorderingDnDHost {
        ReorderingDnDHost(onDragBegun: {
            data.draggingId = elementId
        }, onDragEnded: {
            data.draggingId = UUID()
        }, onDropEntered: {
            guard data.draggingId != elementId else {
                return
            }

            let _from = data.array.firstIndex {
                $0.id == data.draggingId
            }
            let _to = data.array.firstIndex {
                $0.id == elementId
            }

            guard let from = _from, let to = _to else {
                return
            }

            withAnimation {
                data.array.move(fromOffsets: IndexSet(integer: from), toOffset: to + (from < to ? 1 : 0))
            }
        })
    }

    func updateUIView(_ uiView: ReorderingDnDHost, context: Context) {
        uiView.onSnapshot = onSnapshot
    }
}

extension View {
    func snapshot() -> UIImage {
        let _view = UIHostingController(rootView: self).view

        guard let view = _view else {
            return UIImage()
        }

        let targetSize = view.intrinsicContentSize
        view.bounds = CGRect(origin: .zero, size: targetSize)
        view.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)

        return renderer.image { _ in
            view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
        }
    }

    func reorderable<Data: ReorderableData>(data: Data, elementId: UUID) -> some View {
        self
            .opacity(data.draggingId == elementId ? 0 : 1)
            .overlay(
                ReorderingHost(data: data, elementId: elementId) {
                    self
                        .padding(10)
                        .snapshot()
                }
            )
    }
}

// MARK: - カスタマイズする部分

struct ColorElement: ReorderableElement {
    let id = UUID()
    let color: Color
}

class Palette: ReorderableData {
    @Published var array: [ColorElement]
    @Published var draggingId = UUID()
    
    init(colors: [Color]) {
        self.array = colors.map {
            ColorElement(color: $0)
        }
    }
}

struct ReorderingElements: View {
    @ObservedObject var palette: Palette
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: Array(repeating: GridItem(.fixed(80)), count: 4), spacing: nil) {
                ForEach(palette.array) { colorData in
                    Circle()
                        .fill(colorData.color)
                        .frame(width: 50, height: 50)
                        .padding()
                        .reorderable(data: palette, elementId: colorData.id)
                }
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        ReorderingElements(palette: Palette(colors: (0..<100).map {
            Color(hue: Double($0) / 100, saturation: 1, brightness: 1)
        }))
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

# 既知の問題

## 半透明になる
Previewが半透明になってしまいます。
これは調べても設定方法がわからなかったので、どなたか知っていれば教えていただきたいです。

## デフォルトのアニメーションになる
`Lazy*`系のコンテナなどで要素が消滅した場合、Previewのターゲットである戻り先の`UIView`がなくなるので、ドラッグ終了時のアニメーションが大きくなってフェードアウトになってしまいます。

## 少し小さくなる
スクリーンショットの前に余白を取っているせいか、`contextMenu(_:)`を併用すると、そのメニューのPreviewを抜け出してドラッグする場合に最初だけドラッグのPreviewが小さくなってしまいます。

# Q. どうして`onDrag(_:)`や`onDrop(of:delegate:)`を使わないの？
A. `onDrag(_:)`ではドラッグが終了した時に通知されないからです。ドラッグの終了を検知できないと、要素が透明になったままになってしまいます。

`onDrop(of:delegate:)`に関しては使えますが、統一感を出すために使いませんでした。

# 余談
最初は`onDrag(_:)`と`onDrop(of:delegate:)`を使ったものを実装していました。
しかし、ドラッグがキャンセルされた時や想定する領域外にドロップされた時に対応できないという問題がありました。

まず`TapGesture`や`DragGesture`でそれらの検出を試みました。
`gesture(_:)`だけでなく`simultaneousGesture(_:)`や`highPriorityGesture(_:)`も使い、`onDrag(_:)`の前後や、その`View`の子、親などに配置しましたが、ドラッグされた後はコールバックされなかったり、ドラッグが開始されなくなったりしました。

次に`UIGestureRecognizer`で`UIEvent`を受け取ってみましたが、同じようにできませんでした。

その次にPreviewの出現と消滅を検出しようと`onAppear(perform:)`と`onDisappear(perform:)`も試しました。
これらも色々な場所に配置しましたが、そもそも元の`View`は出現したり消滅したりしないのでできませんでした。

今度はPreviewが`View`の位置に戻ってきた瞬間を捉えようと`GeometryReader`を使いました。
しかし、Previewが別のツリーの中にあるようだったのでできませんでした。

そして`onDrag(_:)`が終了したときに起こるイベントはないか探しました。
実はこれに１週間ほどかけましたが、見つかりませんでした()
`NSItemProviderReading`や`NSItemProviderWriting`も調べました。

ならば`onDrag(_:)`の内容を自分で実装してやろうと思い、`LongPressGesture`と`DragGesture`で作ろうとしました。
ドラッグ操作自体はうまく行きましたが、`ScrollView`の領域の端にドラッグした時にスクロールされませんでした。
ドラッグでスクロールさせるようにもできたかもしれませんが、実装側に負担がかかりそうだと思ったので諦めました。
ドラッグしている時に、同時に`NSItemProvider`を持っていればスクロールされるかもしれないと思い、UIKitのドラッグ＆ドロップについて調べていた時にこの解決策に至りました。

# さいごに
私はまだSwiftもSwiftUIも始めたばかりで、UIKitのことは全然わかりません。
こんな実装の方がいいとか、こんな方法でも実現できるとか、その記法は間違っているとかがあれば教えていただきたいです。

# 追記
## 2021年08月29日
要素の`View`が更新された場合にPreviewの見た目が反映されない問題を修正しました。

# 参考文献

この3つ以外にも本当にたくさんのサイトを参考にさせていただきましたが、すみませんが多いので省略させていただきます。

https://stackoverflow.com/questions/62606907/swiftui-using-ondrag-and-ondrop-to-reorder-items-within-one-single-lazygrid
https://qiita.com/hironytic/items/a8ffdf37cfa768d92ced
https://www.hackingwithswift.com/quick-start/swiftui/how-to-convert-a-swiftui-view-to-an-image