LoginSignup
21
16

More than 5 years have passed since last update.

collection viewとtable viewにおけるDrag & Drop[WWDC 2017]

Posted at

なんとかWWDC2018が始まる前に投稿できた。
ここではWWDC2017のDrag and Drop with Collection and Table Viewの中身を日本語訳していきます。日本語的に少し不自然なところは後から直します。

概要

Table View, Collection ViewでのDrag & Dropの活用事例を紹介するために写真アプリを作って説明しています。このアプリではアルバム間(Table ViewとCollection View間)で写真の移動や並び替えができます。

UICollectionView、UITableViewにおけるDrag & DropのAPIには次の特徴があります:

  • cellとindex pathに焦点を当てている
  • アニメーションを用意に実装でき、かつそれがtable view、collection viewで一貫している
  • 非同期でのデータローディングができる
  • table viewとcollection viewとでは、名前がちょっと違うけど機能が似たAPIが存在する

このセッションでは以下のことについて話されています:

  • 基礎的な使い方
  • より良いDropの作り方(アニメーションなど)
  • タッチに関するカスタマイズ

delegate

dragDelegate、dropDelegateはtable view、collection viewどちらにもあり、ドラッグ&ドロップに関する様々な定義をすることができます。この2つのdelegateはどちらか一方だけでも、両方でも使うことができます。

ドラッグの基本実装

実装必須
// セルのドラッグ開始時に呼ばれる
// 空の配列を返すとドラッグは無視される
func collectionView(_ collectionView: UICollectionView, 
           itemsForBeginning session: UIDragSession, 
                        at indexPath: IndexPath) -> [UIDragItem]

ドラッグ中に他のアイテムをタップすると、そのアイテムもドラッグ対象に追加できます。実現するには以下のメソッドを使います。

オプトイン
// 空の配列を返した場合は普通のタップとして扱われる
optional func collectionView(_ collectionView: UICollectionView, 
                     itemsForAddingTo session: UIDragSession, 
                                 at indexPath: IndexPath, 
                                        point: CGPoint) -> [UIDragItem]

ドロップの基本実装

実装必須
// ドロップのハンドリングはここ
func collectionView(_ collectionView: UICollectionView, 
         performDropWith coordinator: UICollectionViewDropCoordinator)

ユーザがcollection view上でドラッグしたアイテムを離すとドロップが実行されます。DropCoordinatorはドロップされたアイテムへアクセスしたり、collection view、table viewの更新をしたり、アニメーションを付与するのに役立ちます。

デモアプリに沿った説明

collection view内の画像をドラッグして、他のアプリ上でドロップ、画像を取り込むという流れを作ります。

実装必須メソッド
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
    let photo = self.photo(at: indexPath)
    let itemProvider = NSItemProvider(object: photo.image) // 自作オブジェクトを扱う時は渡せるか確認
    let dragItem = UIDragItem(itemProvider: itemProvider)
    return [dragItem]
}

今回はドラッグ中に複数のアイテムをまとめて扱えるようにしたいので、次のメソッドを追加します。

// 中身はitemsForBeginningメソッドと同じ。
func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
    let photo = self.photo(at: indexPath)
    let itemProvider = NSItemProvider(object: photo.image)
    let dragItem = UIDragItem(itemProvider: itemProvider)
    return [dragItem]
}

ドロップに関しては以下のようになります。

// ドロップに関する情報を取得、実行
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
    // ドロップした位置からindex pathが算出される
    // 空のcollection view, table viewだった場合はnilが返るので先頭に追加
    let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)

    // クラスを指定して読み込み
    coordinator.session.loadObjects(ofClass: UIImage.self) { (images) in
        collectionView.performBatchUpdates({
            self.insertImages(images, at: destinationIndexPath)
            collectionView.reloadSections(IndexSet(integer: 0))
        })
    }
}

ドロップアニメーション

drop proposalはユーザが実際にドロップを実行する前に、ドロップをどうハンドリングするかをシステムに伝えるものです。

システムは、ユーザがcollection view, table view上をドラッグしている間、drop proposalを作成して返すよう求めます。collection view, table viewはそれぞれUIDropProposalのサブクラスを持ち、copy, move, cancel, forbiddenのような同じプロパティを使うことができますし、それとは別にdrop intentというものもあります。これはドロップをしようとしている時の見た目に関する付加情報です。

  • unspecified
    • drop intentのデフォルト値
    • ドロップセッション中にcollection view, table viewの見た目は変わらない
    • ユーザがドラッグしてる最中に小さなバッジを付けるだけ
    • ユーザがどこでドロップ操作を終えるかわからないとき、複数のアイテムをドラッグしていてドロップする場所が異なるときなどに使う
  • insertAtDestinationIndexPath
    • 新しい要素をcollection view, table viewに追加したい時にこれを使う
    • ドロップできる箇所であればセルとセルの間に空間を空けて、ここがドロップできると教えてくれる
  • insertIntoDestinationIndexPath
    • フォトアルバム、ファイルフォルダ、リストセルなどで使われる
    • ディレクトリのような、アイテムの親になっているものに対してドロップしたい時に使う
    • ユーザが、ここがドロップできると認識できるよう、ドロップ先がハイライトする
  • automatic
    • UITableViewに固有のもの
    • insertIntoDestinationIndexPathの代わりに使うもの
    • すでにある行にドロップしたり、新しい行を追加したりすることもできる

デモアプリに沿った説明

オプショナルメソッド
// オプショナルメソッドだが、実装した方がいい
// tableview, collectionview上をドラッグしている間はずっと呼ばれるので、メインスレッドで処理しない、
// 早めにreturnするなど効率性を考えること
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
    // ドラッグ元が自分のアプリかどうかを判断
    // 新しいアイテムの挿入なのでinsertAtDestinationIndexPathを使用
    if session.localDragSession != nil {
        return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
    } else {
        return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
    }
}

cellではない部分にドラッグしているような時などにはdestinationIndexPathがnilになることもあります。table view, collection viewの最後のセクションにドラッグした場合は、セクション内のアイテムのカウントとindex pathが等しくなり、セクションに存在するアイテムと一致しないことになるので気をつけてください。

今度はユーザが実際に手を離してドロップ操作が必要になった時の話をします。

何もしなくてもcollection view, table viewはデフォルトのアニメーションを実行できますが、より良くするためにはdrop coordinatorを使って特別なアニメーションを定義した方がいいです。いくつか紹介します。

Drop to an item/row

まずはinsertAtDestinationIndexPathを使ったドロップアニメーションです。
新しいアイテムをtable view, collection viewに挿入、更新し終わった時にこれを使います。

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
    guard let destinationIndexPath = coordinator.destinationIndexPath,
          let dragItem = coordinator.items.first?.dragItem,
          let image = dragItem.localObject as? UIImage
    else { return }

    collectionView.performBatchUpdates({
        self.imageArray.insert(image, at: destinationIndexPath.item)
        collectionView.insertItems(at: [destinationIndexPath])
    })

    coordinator.drop(dragItem, toItemAt: destinationIndexPath)
}

まず、裏で同期取得してきたドラッグアイテムを取得します。次に取得したアイテムをすぐにcollection viewに入れるためにperformBatchUpdatesを呼びます。実際にアイテムがセルに挿入されているので、drop coordinatorに特定のindex pathに挿入するよう指示できます。これでドラッグプレビューのアニメーションを設定することができます。

Drop into an item/row

次のアニメーションはアイテムや行内にドロップする時のものです。これはだいたいinsertIntoDestinationIndexPathを使っている時に使われます。この場合、コンテナcellに挿入することになるので、アニメーションはだんだん小さくなるものになります。またサンプルコードは以下の通りです。

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
    // 最初の部分はさっきのコードと同じ
    guard let destinationIndexPath = coordinator.destinationIndexPath,
          let dragItem = coordinator.items.first?.dragItem,
          let image = dragItem.localObject as? UIImage
    else { return }

    let photoAlbumIndex = destinationIndexPath.row
    // destinationIndexPathが実際のrowと一致しているかチェック
    guard photoAlbumIndex < self.albumsArray.count else { return }
    self.add(image: image, toAlbumAt: photoAlbumIndex)

    // アニメーションに関わる部分
    if let cell = tableView.cellForRow(at: destinationIndexPath), let view = cell.imageView {
        let rect = cell.convert(view.bounds, from: view)
        coordinator.drop(dragItem, intoRowAt: destinationIndexPath, rect: rect)
    }
}

Drop to a target

3つめのアニメーションはあるターゲットにドロップする時に使います。例えばtransformを使用して任意の場所にアニメーションする時などです。それはタブバーだったりバーボタンだったりするかもしれません。


3つのアニメーションがありましたが、最初に紹介したアニメーションに話を戻したいと思います。先に述べたように、新しいセルを挿入するためには先程のメソッドを使う前にcollection view, table viewの更新を行う必要があります。まだデータの読み込みが終わっていない時にアプリ間でドラッグを実行すると、データは常に非同期的に読み込まれます。しかしこれらのアニメーションはperformDropWithのcoordinatorが返ってくる前に指定する必要があります。この場合、アニメーションをするにはcollection view, table viewに対して一時的にアイテムを挿入するという難しい操作が必要になります。もちろんモデルオブジェクトの方も一時的に更新しなければならないため、簡単ではありません。非同期で読み込む場合、順番が崩れることもあります。iOS11ではこのあたりの問題に関する大きな変更があります。

Placeholder

これからplaceholderについて説明します。placeholderは名前の通り、一時的にアイテムやrowをcollection view, table viewに挿入します。ポイントとしては、placeholderを挿入する時データの読み込みが終わるまでデータソースの更新を遅らせることができるということです。

データ読み込みが終わるまでの間、これらの難しい作業がバックエンドで行われます。collection view, table viewのdelegateはこのplaceholderには反応しないようになっているので気にせず使えます。

placeholderはcollection view, table viewのどこにでも、いくつでも挿入することができます。アプリ間でデータ転送中に操作を可能にしたりすることでユーザに素晴らしい体験を提供することができます。

placeholderはdrop coordinatorを使って作成します。またコードを使って説明します。

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

    for item in coordinator.items {
        coordinator.drop(item.dragItem, toPlaceholerInsertedAt: destinationIndexPath, withReuseIdentifier: "PlaceholderCell") { cell in
            // Configuer the placeholder cell
        }
    }
}

※上記drop(_:toPlaceholderInsertedAt:withReuseIdentifier:cellUpdateHandler:)メソッドは、iOS 11 Beta 4時点で使えなくなっているようで、実際Xcode9.3.1で自動補完に出てきませんでした。

ドロップセッションで運ばれてきたitemを個々にplaceholderに入れます。drop(_:toPlaceholderInsertedAt:withReuseIdentifier:cellUpdateHandler:)メソッドでは、実際にdestinationIndexPathにplaceholderを入れる処理をしています。identifierによって、すでに登録済みかどうかを判断しつつ処理を行います。クロージャー内でplaceholderの設定をします。先に述べたように、placeholderはdelegateやdata sourceを呼び出さないので、rowやitemの呼び出しも行わないことになります。そのかわり、このクロージャ内で通常cellForRowAtIndexpathcellForItemInIndexpathで行うものを実行します。

placeholderを挿入するとplaceholderごとにplaceholderContextが返ってきます。これによって、データ読み込みが終わったときに最後のセルをうまく処理したり、データ転送が失敗した時、ユーザが処理をキャンセルした時に立て直すことができます。いらなくなったら削除することもできます。コードに戻ります。

for item in coordinator.items {
    let placeholderContext = /* insert the placeholder for this item */
        item.dragItem.itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in
            DispatchQueue.main.async {
                if let image = object as? UIImage {
                    placeholderContext.commitInsertion { insertionIndexPath in
                        self.imagesArray.insert(image, at: insertionIndexPath.item)
                    }
                } else {
                    placeholderContext.deletePlaceholder()
                }
            }
    }
}

非同期、バックグラウンドでデータを読み込み、UIを更新する前にmainキューに戻るようにします。そこでデータが実際にロードされたかどうかをチェックし、もし存在していればローカル変数に持つようにします。commitInsertionを呼び出すと、placeholderと実際のセルの置き換えが発生します。このクロージャ内でのデータ更新は自分自身で行います。この時、最初にplaceholderを挿入した元のdestinationIndexPathではなく実際のindexPathを指定しなければならないことに注意しなければいけません。これはplaceholderを挿入してからデータのロードが完了するまでの間にcollection viewまたはtable viewで他の更新が発生した場合、placeholderが移動した可能性があるからです。データ転送が何らかの理由でできない場合、またはキャンセルされた可能性がある場合はplaceholderを削除することができます。

placeholderを使う時の注意点としては、差分更新が適切に行われるようにreloadDataは使わずperformBatchUpdates(_:completion:)を使うべきということです。reloadDataを使ってしまうとcollection view, table viewを一括更新してしまいplaceholderもなくなってしまいます。

placeholderがまだ残っているかどうかはhasUncommittedUpdatesプロパティを使って知ることができます。

ここでデモに入ります。
重要なこととしては、最初にcollection viewに対してitemをあるpointにアニメーション移動したいことを伝えることです。実装はこのようになります。

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
    let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)

    for item in coordinator.items {
        let itemProvider = item.dragItem.itemProvider

        if itemProvider.canLoadObject(ofClass: UIImage.self) {
            let placeholderContext = coordinator.drop(item.dragItem, toPlaceholderInsertedAt: destinationIndexPath, withReuseIdentifier: "PlaceholderCell") { _ in }
            itemProvider.loadObject(ofClass: UIImage.self) {(object, error) in
                DispatchQueue.main.async {
                    if let imaeg = object as? UIImage {
                        placeholderContext.commitInsertion { insertionIndexPath in
                            self.insertImage(image, at: insertionIndexPath.item)
                        }
                    } else {
                        placeholderContext.deletePlaceholder()
                    }
                }
            }

        }
    }
    coordinator.session.progressIndicatorStyle = .none
}

func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
    return UICollectionViewDropProposal(dropOperation: .copy, intent: .insertAtDestinationIndexPath)
}

最初にドロップされたitemを取り出し、UIImageオブジェクトをロードします。ロードできたらdestinationIndexPathにplaceholderをドロップできるかを確認して取り出します。以前にcollection viewに登録していたcellがあればidentifierを元に再利用します。次に、後でcollection viewを更新する時のためにplaceholderContextを取得、loadObjectで実際にデータをロードします。loadObjectはbackgroundキューで呼び出されていますが、ここでUI更新のためにmainキューに戻ってきていることに注意してください。データ転送が成功したらcommitInsertionを呼び出してドロップを確定させます。データ転送が失敗したらplaceholderを削除します。もう一つやることとしては、長時間データ転送が実行されるときのためにデフォルトのインジケータを無効にすることです。

デバイス上で実行してみます。
今回の実装の効果を十分に得るために、異なる写真アプリからドロップしてみます。ここでは意図的にデータ転送が遅くなるようにしています。いくつかの写真を今回作ったcollection viewにドロップします。collection viewにhoverするとcellが移動してspringboardっぽい動きをして並べ替えができることがわかります。これはinsertAtDestinationIndexPathを返しているからです。itemを離すとアニメーションがスタートします。最初はインジケータが表示され、データ転送が完了すると実際の画像に置き換わります。

並べ替えのサポート

ドラッグ&ドロップを実装したので、次は並べ替えをサポートしたいと思うかもしれません。もちろんそれは簡単にできます。まずdropDelegateのcollectionView(_: UICollectionView, dropSessionDidUpdate: UIDropSession, withDestinationIndexPath: IndexPath?)を実装します。そしてUICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)を返します。これによって、同じところからドラッグされた場合に反応することができます。これはcollection viewとtable viewで少し異なるところです。

table viewではすでに長年に渡って並べ換えをサポートしてきました。それがfunc tableView(_: UITableView, moveRowAt: IndexPath, to: IndexPath)です。これはtableView(_: UITableView, performDropWith: UITableViewDropCoordinator)の代わりに呼ばれます。collection viewでも同じように、簡単に並べ替えをすることができます。こちらの場合はdragDelegateとdropDelegateを実装します。collectionView(_: UICollectionView, performDropWith: UICollectionViewDropCoordinator)の中ではドロップされたitemが渡されます。もしそれが実際の特定のitemを表していたら、sourceIndexPathが渡されます。これでsourceIndexPathでitemが削除され、destinationIndexPathでitemを挿入するサイクルがシームレスにできます。

collection viewに関しては並べ替えに関する追加機能がいくつかあります。それがreorderingCadenceプロパティです。collection viewは2次元の格子状のレイアウトをしていることがしばしばあります。今スクリーンに映しているのはフローレイアウトですが、これは並べ替えをしている時にitemが再配置されるようになっています。しかし必ずしもこの動きを望んではいないと思います。その場合はこのプロパティに適切な値をセットすることでハンドリングすることができます。デフォルトでは.immediateが設定されています。ドラッグを始めるとすぐにレイアウトが変化します。もう少し速くしたい場合は.fastを選択してください。短い遅延ですばやく動きます。.slowを選択すればワンテンポ遅らせることができます。iPhoneのホーム画面でアイコンを並べ替える時と同じような動きになります。

スプリングローディング

スプリングローディングはドラッグ中に選択した項目の中に入り込みアクティブにするものです。項目の上にhoverするだけでハイライトし、アクティブになります。collection view、table viewはUISpringLoadedInteractionSupportingプロトコルに準拠しているので、スプリングローディングに対応させることは簡単です。どちらもisSpringLoadedプロパティを持っており、これがtrueだとスプリングローディングが機能するようになります。私達はrowやitemを選択するとこれを発動させることができます。

新しく追加されたcollectionView(_:shouldSpringLoadItemAt:with:)メソッドを使ってカスタマイズすることもできます。これはあるrowをどのようにスプリングローディング表示させるかを決めるものです。例えばスプリングローディング中にviewを点滅させることができます。

セルの見た目のカスタマイズ

最初は.none状態ですが、ユーザが要素に指をおいて持ち上げ始めると.liftingモードに切り替わります。こちらの例では各セルに小さなバナーが表示されています。リフトし終わるとセルがドラッグ状態に移行します。この時デフォルトではalpha値が下がってfade状態になります。これらの状態変化はdragStateDidChange(_:)をオーバーライドすることで検知できます。superを呼び出してデフォルトの見た目と挙動を取得することもできますし、オーバーライドして独自実装を加えることもできます。アニメーションクロージャの内側と外側で状態を取得できるので、リフトの前後にアニメーションを加えることもできます。

ドラッグプレビューのカスタマイズ

ここに四角形のセルがあります。画像コンテンツが横長である場合、上下に白いバーができたプレビューになってしまい、あまりかっこよくありません。デフォルトではセル全体をプレビュー領域とするのでこうなってしまいます。もちろんこれは変えることができます。func collectionView(_:UICollectionView, dragPreviewParametersForItemAt: IndexPath) -> UIDragPreviewParameters?メソッドを使うと、例えばセル内の特定の領域をクリップするベジェパスを作れるので、セルは正方形のままではあるものの、プレビューは画像の矩形に合わせるということもできます。

Next Step

まずは自分のアプリの全てのcollection view、table viewにドラッグ&ドロップを入れることをおすすめします。導入するのは簡単です。次にdrop proposalを活用し素晴らしいアニメーションを設定してください。placeholderを入れることも忘れないでください。非同期でデータを読み込んでいる時、簡単にローディングの管理とUIのインタラクティブ性を保てます。最後に、細部を磨くということを忘れないでほしいです。我々は皆さんがこれらのAPIを使ってどのようなことをするのかが楽しみです。より多くの情報を得たい場合は今回見せたサンプルアプリ、このセッションビデオを御覧ください。他にもIntroducing Drag and DropMastering Drag and DropData Delivery with Drag and Dropなどの関連セッションがあるのでそちらもご覧下さい。

21
16
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
21
16