UITableViewのdragDelegate, dropDelegateについて調べて試した

UITableViewでdragDelegate, dropDelegateを使って、ドラッグ&ドロップ機能を実装する方法を調べました。既出の技術ですが、備忘録がてら記事にしました。dragDelegate, dropDelegateはiOS11以降で使用することができます。

公式ドキュメント

Appleの公式のドキュメントがあります。サンプルプロジェクトもダウンロードできますので、ビルドして動きを確認することができます。

環境

  • Xcode11.2.1
  • macOS Catalina バージョン10.15

1から実装する

前準備その1(Storyboardとの接続)

プロジェクトを作って最初に表示するUIViewControllerにUITableViewを配置します。Storyboardで配置しました。IBOutletで接続します。

前準備その2

UITableViewに、都道府県の一覧が表示されるようにします。この記事の本筋ではないのでソースコードだけさらっと載せておきます。

class ViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!

    let resource = TableResourceModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return resource.prefectureNames.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = resource.prefectureNames[indexPath.row]
        return cell
    }
}
struct TableResourceModel {
    private(set) var prefectureNames = [
        "北海道",
        "青森県",
        ...
        "鹿児島県",
        "沖縄県"
    ]
}

CellのStyleはBasicにしています。

実装

TableViewのDelegateを設定

UITableViewのdragDelegatedropDelegateを設定します。それぞれ実装必須なメソッドがありますが、 今は一旦、ビルドが通る最小限の実装にしておきます。dragInteractionEnabledプロパティは、iPhoneアプリではデフォルトでfalseになっているため、trueを設定します。

class ViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        tableView.dragDelegate = self
        tableView.dropDelegate = self
        tableView.dragInteractionEnabled = true
    }
}

extension ViewController: UITableViewDragDelegate {
    func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        // Todo: implementation
        return []
    }
}

extension ViewController: UITableViewDropDelegate {
    func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
        // Todo: implementation
    }
}

ドラッグ部分を実装する

  • ViewController
extension ViewController: UITableViewDragDelegate {
    func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        return [resource.dragItem(for: indexPath)]
    }
}

tableView(_:itemsForBeginning:at:)こちらのメソッドがドラッグ開始時に呼ばれます。ドラッグを開始されたセルのindexPathが渡されます。このメソッドではドラッグされたコンテンツを表すUIDragItemを配列で返す必要があります。空の配列を指定した場合、ドラッグしないことを示します。

  • Model
extension TableResourceModel {
    func dragItem(for indexPath: IndexPath) -> UIDragItem {
        let prefectureName = prefectureNames[indexPath.row]
        let itemProvider = NSItemProvider(object: prefectureName as NSString)

        return UIDragItem(itemProvider: NSItemProvider())
    }
}

ヘルパーメソッドとしてdragItem(for indexPath: IndexPath) -> UIDragItemメソッドをModelに実装しています。indexPathよりモデルの中の都道府県配列からドラッグされた都道府県の文字列を取得し、UIDragItemを生成して返却しています。NSItemProviderはドラッグアンドドロップ機能を実装をする上で、中心的な役割を担います。※ModelにUIKitをimportしていますが、説明の便宜上こういう実装にしています

動作

ここまでの実装で、ドラッグ部分ができました。動作は以下のようになります。

ドロップ部分を実装する

次は、ドラッグしたセルをドロップした時の動作を実装していきます。

  • ViewController
extension ViewController: UITableViewDropDelegate {
    func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, 
        withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
        return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
    }

    func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
        guard let item = coordinator.items.first,
            let destinationIndexPath = coordinator.destinationIndexPath,
            let sourceIndexPath = item.sourceIndexPath else { return }

        tableView.performBatchUpdates({ [weak self] in
            guard let strongSelf = self else { return }
            strongSelf.resource.moveItem(sourcePath: sourceIndexPath.row, destinationPath: destinationIndexPath.row)
            tableView.deleteRows(at: [sourceIndexPath], with: .automatic)
            tableView.insertRows(at: [destinationIndexPath], with: .automatic)
            }, completion: nil)
        coordinator.drop(item.dragItem, toRowAt: destinationIndexPath)
    }
}
  • Model
  mutating func moveItem(sourcePath: Int, destinationPath: Int) {
      let prefecture = prefectureNames.remove(at: sourcePath)
      prefectureNames.insert(prefecture, at: destinationPath)
  }

tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) でドロップされた時の処理方法を、UITableViewDropProposalを返却することで提案します。このメソッドはTableview上でドラッグしている間何度もコールされます。なので、可能な限り軽い処理でUITableViewDropProposalを返却するようにします。tableView(_:performDropWith:)では、ドロップされたデータを実際に取り込んでテーブルを更新します。上記の例ではドロップされた時に、モデルからドラッグされたアイテムを削除し、ドロップされた位置に追加しています。と、同時にTableViewの行も同様に削除、追加を行なっています。coordinatorのdropメソッドでdropのアニメーションが実行されます。

動作

ここまでの実装で、ドラッグ&ドロップでセルの位置を変更することができました。

複数のUITableViewをまたぐ

複数のUITableViewをまたぐドラッグ&ドロップ機能を実装してみます。 今回実装したのは以下のような内容です。

  1. FirstTableViewとSecondTableViewが1画面に存在します
  2. FirstTableViewのセルのみ、ドラッグができます
  3. FirstTableView内でのドラッグ&ドロップは先ほどと同じようにセルを入れ替えます
  4. SecondTableViewにドロップした場合は、ドラッグしたセルをコピーしてInsertします

storyboardの実装

以下のように、実装します。SecondTableViewにはPlaceholder用のセルを用意しています。

実装

  • ViewController 解説用にわかりやすくするためにFatViewControllerになっていますが、実際に実装する時は適宜綺麗にしてください。
class ViewController: UIViewController {
    @IBOutlet weak var firstTableView: UITableView!
    @IBOutlet weak var secondTableView: UITableView!

    var firstResource = TableResourceModel()
    var secondResource = TableResourceModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        firstTableView.dataSource = self
        firstTableView.dragDelegate = self
        firstTableView.dropDelegate = self
        firstTableView.dragInteractionEnabled = true

        secondTableView.dataSource = self
        secondTableView.dragDelegate = self
        secondTableView.dropDelegate = self
    }
}
extension ViewController: UITableViewDragDelegate {
    func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        return [firstResource.dragItem(for: indexPath)]
    }
}
extension ViewController: UITableViewDropDelegate {
    func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
        let dropOperation: UIDropOperation = tableView == firstTableView ? .move : .copy
        return UITableViewDropProposal(operation: dropOperation, intent: .insertAtDestinationIndexPath)
    }

    func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
        return true
    }

    func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
        guard let item = coordinator.items.first else { return }
        guard let destinationIndexPath = coordinator.destinationIndexPath else { return }
        if let sourceIndexPath = item.sourceIndexPath, tableView == firstTableView {
            tableView.performBatchUpdates({ [weak self] in
                guard let strongSelf = self else { return }
                strongSelf.firstResource.moveItem(sourcePath: sourceIndexPath.row, destinationPath: destinationIndexPath.row)
                firstTableView.deleteRows(at: [sourceIndexPath], with: .automatic)
                firstTableView.insertRows(at: [destinationIndexPath], with: .automatic)
                }, completion: nil)
            coordinator.drop(item.dragItem, toRowAt: destinationIndexPath)
        } else {
            _ = item.dragItem.itemProvider.loadObject(
                ofClass: NSString.self,
                completionHandler: { (data, error) in
                    guard error == nil else { return }
                    DispatchQueue.main.async {
                        let placeHolder = UITableViewDropPlaceholder(
                            insertionIndexPath: destinationIndexPath,
                            reuseIdentifier: "PlaceholderCell",
                            rowHeight: UITableView.automaticDimension)

                        let placeHolderContext = coordinator.drop(item.dragItem, to: placeHolder)
                        placeHolderContext.commitInsertion(dataSourceUpdates: { (insertionIndexPath) in
                            if let prefecture = data as? String {
                                self.secondResource.insertItem(prefecture: prefecture, destinationPath: destinationIndexPath.row)
                            }
                        })
                    }
            })
        }
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return tableView == firstTableView ? firstResource.prefectureNames.count : secondResource.prefectureNames.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if tableView == firstTableView {
            let cell = tableView.dequeueReusableCell(withIdentifier: "FirstCell", for: indexPath)
            cell.textLabel?.text = firstResource.prefectureNames[indexPath.row]
            return cell
        } else {
            let cell = tableView.dequeueReusableCell(withIdentifier: "SecondCell", for: indexPath)
            cell.textLabel?.text = secondResource.prefectureNames[indexPath.row]
            return cell
        }
    }
}

dropSessionDidUpdateメソッド

ドラッグ中に、ドロップしたらどんなアクションが発生するかが分かるUIを、UIDropOperationを切り替えて指定しています。 FirstTableViewをドラッグ中は、行の移動だということがわかります。SecondTableViewの上をドラッグしている時は、行がコピーされる、ということがわかるUIが表示されています。

move copy

performDropWithメソッド

SecondTableViewにドロップされた時にcoordinatorを介してドラッグされたアイテムを取得します。ドロップされた時のアニメーションが走る際、Placeholderのセルを用意して表示します(一瞬、Placeholderと書かれたセルが表示されています)。実際にドロップアニメーションが終わった時にsecondTableViewのresourceにドロップしたオブジェクトを追加しています。

動作

ここまでの実装で以下のような動作になりました。

NSItemProviderを介したオブジェクトのやりとり

peformDropWithメソッドにて、itemProviderのloadObjectにて、ドラッグ&ドロップされたオブジェクトを取得しています。今回の例ではNSStringですが、カスタムクラスをNSItemProviderにてやりとりする場合は、NSItemProviderWriting, NSItemProviderReadingプロトコルに準拠させる必要があります。詳細については以下の記事が参考になります。

終わり

なんの気なしに掘ってみたら思ったより深かった。

参考