Swift：NSCollectionViewの使い方

# はじめに

`NSCollectionView`を使ってアイテムをいい感じに並べて，インタラクションできるようにしたいと思ったのですが，これがなかなか厄介でした．
本記事では`NSCollectionView`の基本的な実装の仕方，`NSCollectionViewItem`の設定の仕方，プログラムによるアイテムの追加と削除の仕方，ドラッグ&ドロップによるアイテムの入れ替え方の仕方をまとめます．

# Storyboardの下準備

## NSCollectionViewを配置

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/49d69470-21d5-2417-4280-227346626373.png" width=50%>
普通に置いて，AutoLayoutなどの設定をします．

## ViewController.swiftと紐づける

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/17c81112-b030-c15c-fbb8-de26c0a5c03c.png" width=50%>
<font color="Crimson">ここ要注意！</font>普通にアイテムを選んで紐づけると`NSScrollView`になってしまうので，リストの方から確実に`CollectionView`を選んで紐づけましょう．

## NSCollectionViewItemを用意

新規ファイルからカスタムクラスを作りましょう．ここではSampleItemとしています．
![coll2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/d5708f9b-7dae-4eb7-c32a-c9e322171a54.png)
このとき，`xib`ファイルも一緒に作ります．（xibファイルなしの方法が見つからなかったです．）

SampleItem.xibを開いて`Collection View Item`を追加します．
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/2628d901-b308-5569-d81c-85fd30965305.png" width=50%>
下ようになるはず．
![coll4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/860e2f6d-6f1a-fcab-d252-da5da9fcdf99.png)
そうしたら，カスタムクラスの指定をします．
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/0c02e6b5-6b88-7c02-1ba3-bc4629ccf908.png" width=50%>

view，imageView，textFieldなどをいじるはずなので，それらのUIを配置したあと，紐付けます．
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/4f361f00-8b22-3557-faf0-6d7f5ecb23e6.png" width=40%>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/aeb14f52-f5fa-e660-7a75-8c270ea288fa.png" width=40%>
↑こんな感じになっていればOK

# 基本的な実装

```swift:ViewController.swift
import Cocoa

class ViewController: NSViewController {

    @IBOutlet weak var collectionView: NSCollectionView!
    
    var data = ["A", "B", "C", "D"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // デリゲートとデータソースの紐付け
        collectionView.delegate = self
        collectionView.dataSource = self
        
        // nibファイルの登録
        let nib = NSNib(nibNamed: "SampleItem", bundle: nil)
        collectionView.register(nib, forItemWithIdentifier: NSUserInterfaceItemIdentifier(rawValue: "sample"))
        
        // データのリロード
        collectionView.reloadData()
    }

}

// デリゲートとデータソースの実装
extension ViewController: NSCollectionViewDelegate, NSCollectionViewDataSource {
   
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return data.count
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        // アイテムの用意
        let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "sample"), for: indexPath) as! SampleItem
        item.textField?.stringValue = data[indexPath.item]
        item.imageView?.image = NSImage(imageLiteralResourceName: "Sample")
        return item
    }
    
}
```

ここまでで，下のように表示できると思います．
![coll8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/f6896ee8-caee-48f5-4833-abdbb28e8f3d.png)

# アイテムの追加

```swift:ViewController.swift
@IBAction func appendData(_ sender: Any) {
    data.append("E") //例えば
    collectionView.reloadData()
}
```

デモ
![append.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/24c4c906-5e2d-0825-6d6f-fd1c451e87f2.gif)

# アイテムの選択（UIの更新例）

```swift:SampleItem.swift
import Cocoa

class SampleItem: NSCollectionViewItem {

    override var isSelected: Bool {
        didSet {
            if isSelected {
                self.view.layer?.backgroundColor = NSColor(deviceWhite: 1.0, alpha: 0.3).cgColor
            } else {
                self.view.layer?.backgroundColor = CGColor.clear
            }
        }
    }
    
}
```

こんな風に選択してあるやつがわかるようになります．
![coll9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/c4dc60c2-4825-45a0-9f77-f20a6bb08e82.png)

# 選択中のアイテムの削除

```swift:ViewController.swift
@IBAction func removeData(_ sender: Any) {
    let indexes = collectionView.selectionIndexPaths.map({ (indexPath) -> Int in
        return indexPath.item
    }).sorted().reversed()
    let count = indexes.count
    if count == 0 { return }
    var minIndex = indexes.min()?.advanced(by: -1) ?? -1
    if minIndex < 0 && 0 <= (collectionView.numberOfItems(inSection: 0) - count) {
        minIndex = 0
    }
    for i in indexes {
        if !data.isEmpty { data.remove(at: i) }
    }
    collectionView.reloadData()
    if 0 <= minIndex && !data.isEmpty {
        collectionView.selectionIndexPaths.insert(IndexPath(item: minIndex, section: 0))
    }
}
```

デモ
![remove.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/02413e15-543d-ae66-28f4-5ba35a8e515a.gif)


# ドラッグ＆ドロップでアイテムの並び替えをする

```swift:ViewController.swift

let pasteBoardType = NSPasteboard.PasteboardType("public.data")

override func viewDidLoad() {
    super.viewDidLoad()

    // デリゲートとデータソースの紐付け
    collectionView.delegate = self
    collectionView.dataSource = self
    collectionView.isSelectable = true
    collectionView.allowsMultipleSelection = true
    // ドラッグタイプを設定
    collectionView.registerForDraggedTypes([pasteBoardType])
    // 以下省略
}


// デリゲートとデータソースの実装
extension ViewController: NSCollectionViewDelegate, NSCollectionViewDataSource {
    // 中略

    // ドラッグ
    func collectionView(_ collectionView: NSCollectionView,
                        writeItemsAt indexPaths: Set<IndexPath>,
                        to pasteboard: NSPasteboard) -> Bool {
        do {
            let data = try NSKeyedArchiver.archivedData(withRootObject: indexPaths,
                                                        requiringSecureCoding: false)
            pasteboard.declareTypes([pasteBoardType], owner: self)
            pasteboard.setData(data, forType: pasteBoardType)
        } catch {
            Swift.print(error.localizedDescription)
        }
        return true
    }
    
    // ↓と間違えないこと
    func collectionView(_ collectionView: NSCollectionView,
                        writeItemsAt indexes: IndexSet,
                        to pasteboard: NSPasteboard) -> Bool {
    }
    
    // ドロップ
    func collectionView(_ collectionView: NSCollectionView,
                        validateDrop draggingInfo: NSDraggingInfo,
                        proposedIndexPath proposedDropIndexPath: AutoreleasingUnsafeMutablePointer<NSIndexPath>,
                        dropOperation proposedDropOperation: UnsafeMutablePointer<NSCollectionView.DropOperation>) -> NSDragOperation {
        if proposedDropOperation.pointee == .before {
            return .move
        }
        return []
    }
    // ↓と間違えないこと
    func collectionView(_ collectionView: NSCollectionView,
                        validateDrop draggingInfo: NSDraggingInfo,
                        proposedIndex proposedDropIndex: UnsafeMutablePointer<Int>,
                        dropOperation proposedDropOperation: UnsafeMutablePointer<NSCollectionView.DropOperation>) -> NSDragOperation { }

    func collectionView(_ collectionView: NSCollectionView,
                        acceptDrop draggingInfo: NSDraggingInfo,
                        indexPath: IndexPath,
                        dropOperation: NSCollectionView.DropOperation) -> Bool {
        let pasteboard = draggingInfo.draggingPasteboard
        guard
            let data = pasteboard.data(forType: pasteBoardType),
            let indexPaths = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? Set<IndexPath>
            else { return false }
        let newIndex = indexPath.item
        let targetIndexes = indexPaths.map({ (path) -> Int in
            return path.item
        }).sorted().reversed()
        let beforeCount = targetIndexes.filter({ (n) -> Bool in
            return n < newIndex
        }).count
        var tmpData = [String]()
        targetIndexes.forEach { (n) in
            tmpData.insert(data.remove(at: n), at: 0)
        }
        data.insert(contentsOf: tmpData, at: newIndex - beforeCount)
        collectionView.reloadData()
        return true
    }
    // ↓と間違えないこと
    func collectionView(_ collectionView: NSCollectionView,
                        acceptDrop draggingInfo: NSDraggingInfo,
                        index: Int,
                        dropOperation: NSCollectionView.DropOperation) -> Bool { }

}
```

<font color="Crimson">要注意！</font>`index`は罠です．`indexPath`のやつを使いましょう．

デモ
![drag_and_drop.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/10bbccd0-11da-8b5b-7d6c-42adb8ce6b63.gif)

# Optionキー + ドラッグで複製する

```swift:ViewController.swift
extension ViewController: NSCollectionViewDelegate, NSCollectionViewDataSource {
    // 中略

    // ドロップ
    func collectionView(_ collectionView: NSCollectionView,
                        validateDrop draggingInfo: NSDraggingInfo,
                        proposedIndexPath proposedDropIndexPath: AutoreleasingUnsafeMutablePointer<NSIndexPath>,
                        dropOperation proposedDropOperation: UnsafeMutablePointer<NSCollectionView.DropOperation>) -> NSDragOperation {
        let copyOrMove: NSDragOperation = (draggingInfo.draggingSourceOperationMask == .copy ? .copy : .move)
        if proposedDropOperation.pointee == .before { return copyOrMove }
        return []
    }

    func collectionView(_ collectionView: NSCollectionView,
                        acceptDrop draggingInfo: NSDraggingInfo,
                        indexPath: IndexPath,
                        dropOperation: NSCollectionView.DropOperation) -> Bool {
        let pasteboard = draggingInfo.draggingPasteboard
        guard
            let data = pasteboard.data(forType: pasteBoardType),
            let indexPaths = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? Set<IndexPath>
            else { return false }
        let newIndex = indexPath.item
        let targetIndexes = indexPaths.map({ (path) -> Int in
            return path.item
        }).sorted()
        var tmpData = [String]()
        // ここが特に新しいところ
        if draggingInfo.draggingSourceOperationMask == .copy {
            targetIndexes.forEach({ (n) in
                tmpData.append(data[n])
            })
            data.insert(contentsOf: tmpData, at: newIndex)
            collectionView.reloadData()
            return true
        } else if draggingInfo.draggingSourceOperationMask == .every {
            let beforeCount = targetIndexes.filter({ (n) -> Bool in
                return n < newIndex
            }).count
            targetIndexes.reversed().forEach { (n) in
                tmpData.insert(data.remove(at: n), at: 0)
            }
            let at = newIndex - beforeCount
            data.insert(contentsOf: tmpData, at: newIndex - beforeCount)
            collectionView.reloadData()
            return true
        }
        return false
    }

}
```

`.reversed()`の位置が変化しているので注意！

# 外部からドラッグしてきた要素を追加する

```swift:ViewController.swift
override func viewDidLoad() {
    // 中略
    
    // ドラッグタイプを設定
    // 今回は文字列
    collectionView.registerForDraggedTypes([pasteBoardType, .string])
    // 以下省略
}


extension ViewController: NSCollectionViewDelegate, NSCollectionViewDataSource {
    // 中略

    func collectionView(_ collectionView: NSCollectionView,
                        acceptDrop draggingInfo: NSDraggingInfo,
                        indexPath: IndexPath,
                        dropOperation: NSCollectionView.DropOperation) -> Bool {
        let pasteboard = draggingInfo.draggingPasteboard
        guard
            let data = pasteboard.data(forType: pasteBoardType),
            let indexPaths = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? Set<IndexPath>
            else { return false }
        let newIndex = indexPath.item
        let targetIndexes = indexPaths.map({ (path) -> Int in
            return path.item
        }).sorted()
        var tmpData = [String]()
        // ここが特に新しいところ
        if let str = pasteboard.string(forType: .string) {
            data.append(str)
            collectionView.reloadData()
            return true
        } else if draggingInfo.draggingSourceOperationMask == .copy {
            targetIndexes.forEach({ (n) in
                tmpData.append(data[n])
            })
            data.insert(contentsOf: tmpData, at: newIndex)
            collectionView.reloadData()
            return true
        } else if draggingInfo.draggingSourceOperationMask == .every {
            let beforeCount = targetIndexes.filter({ (n) -> Bool in
                return n < newIndex
            }).count
            targetIndexes.reversed().forEach { (n) in
                tmpData.insert(data.remove(at: n), at: 0)
            }
            let at = newIndex - beforeCount
            data.insert(contentsOf: tmpData, at: newIndex - beforeCount)
            collectionView.reloadData()
            return true
        }
        return false
    }

}
```