【Swift】CollectionViewCellでセルの範囲選択処理を実装してみた

# 前書き
[こちら](https://qiita.com/m-yamada1992/items/ad1bfa106243e917b2f7)の記事を参考にCollectionViewCellでセルの範囲選択処理を実装しようと思ったのですが、以下のような不都合な点がいくつかあったので、[こちら](https://qiita.com/ddd503/items/ad0bb2397a7529064f70)の記事も参考にしつつ改造してみました。
* [元ネタ](https://qiita.com/m-yamada1992/items/ad1bfa106243e917b2f7)の記事にそもそも「実用に致命的な課題」という部分があり解決されずに放置されていた（多分）。
    （今回は特に以下の課題を解決しました。）
    * タッチイベントが競合してスクロールが効かない
    * スクロールした先の座標の計算を考慮してない
    * カラムが4列の場合しか考慮してない
* 実装されていた処理がAppleの写真等を範囲選択する場合の処理と挙動が違っていました。
* 記事が古く現時点(2022年9月時点)のSwiftに対応していない。

# 完成イメージ
あとで編集して上げたいと思います。

# 本題

現在のソース全部は[こちら](https://github.com/YoshihisaTanaka-prog/DragSelection)にございます。

ViewController内の以下の部分を設定したら使えるようになるはずです。

~~~ViweController.swift(10-15行目付近と40行目付近)
class ViewController: UIViewController {
    // 特に設定する部分
    var data = [String]()
    let numOfRow:CGFloat = 5
    
    @IBOutlet var collectionView: CollectionView!
// -----省略------
        for i in 1...150{
            data.append( String(i) )
        }
// -----省略------
}
~~~

data変数に表示させたいデータをまとめたデータを入れてください。
numOfRow変数にカラムの列数を入れてください。
40行目付近のループを消してください。



# もう少し詳しく解説
主要な部分を抜粋して説明していきます。

## CollwctionView

まず、オリジナルのdelegateを追加して、touchesBegan、touchesMoved、touchesEndedのイベントをViewControllerに送ります。
touchsもViewControllerに送ることで、「スクロールした先の座標の計算を考慮してない」の部分を解決しました。
~~~ CollectionView.swift (10行目付近[オリジナルのデリゲートを追加])
protocol AdditionalDelegate{
    func selectTouchBeganCell(touches: Set<UITouch>, beganPoint: CGPoint?)
    func selectTouchRangeCells(touches: Set<UITouch>, beganPoint: CGPoint?, movedPoint: CGPoint?)
    func selectTouchEndedCells()
}
~~~

~~~ CollectionView.swift (25行目付近[touchesBeganの編集])
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        self.touchesBeganPoint = touches.first?.location(in: self)
        
        // タップした座標のセルの選択ステータスを変更する
        self.additionalDelegate?.selectTouchBeganCell(touches: touches, beganPoint: touchesBeganPoint)
    }
~~~

~~~ CollectionView.swift (35行目付近[touchesMovedの編集])
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesMoved(touches, with: event)
        
        if let locationPoint = touches.first?.location(in: self) {
            self.touchesMovedPoint = locationPoint
        }
        
        // タップした範囲内のセルの選択ステータスを変更する
        self.additionalDelegate?.selectTouchRangeCells(touches: touches, beganPoint: touchesBeganPoint, movedPoint: touchesMovedPoint)
    }
~~~

~~~ CollectionView.swift (50行目付近[touchesEndedの編集])
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        self.touchesBeganPoint = nil
        self.touchesMovedPoint = nil
        self.previewRangeSelectedIndexPathes.removeAll()
        
        // タッチイベントが終了したので、スクロールを可能に戻す
        self.isScrollEnabled = true
        
        // 範囲選択表示を削除する
        self.additionalDelegate?.selectTouchEndedCells()
    }
~~~

## ViewController

続いて、ViewControllerでdelegateの処理部分と、それに必要な設定をします。

まずはdelegateの処理部分です。
後述する自動スクロールの発火もこの部分で行います。
~~~ViewController.swift (50行目付近-120行目付近)
extension ViewController: AdditionalDelegate{
    func selectTouchEndedCells() {
        DispatchQueue.main.async {
            self.shouldFinish = true
            self.stopAutoScrollIfNeeded()
        }
    }
    
    func selectTouchBeganCell(touches: Set<UITouch>, beganPoint: CGPoint?) {
        if let indexPath = self.collectionView.indexPathForItem(at: beganPoint!) {
            self.collectionView.previewRangeSelectedIndexPathes.append(indexPath)
            updateCollectionView(indexPath)
            cachedSelectedIndices = selectedIndices
        }
    }
    
    /**
     移動した座標間に含まれるセルの選択ステータスを変更する
     - paremeter beganPoint: 選択を開始した座標
     - parameter movedPoint: 選択範囲の終端の座標
     */
    func selectTouchRangeCells(touches: Set<UITouch>, beganPoint: CGPoint?, movedPoint: CGPoint?) {
        if let locationPoint = touches.first?.location(in: self.view){
            let timeInterval = 0.1
            if(locationPoint.y <= scrollStartHeightTop){
                // 上にスクロールさせる
                collectionView.isScrollEnabled = true
                startAutoScroll(duration: timeInterval, direction: "up")
            } else if(scrollStartHeightBottom <= locationPoint.y){
                // 下にスクロールさせる
                collectionView.isScrollEnabled = true
                startAutoScroll(duration: timeInterval, direction: "down")
            } else{
                collectionView.isScrollEnabled = false
                stopAutoScrollIfNeeded()
            }
        }
        // 座標内に含まれるセルを取得する
        if let beganPoint = beganPoint, let movedPoint = movedPoint{
            if let beganIndexPath = collectionView.indexPathForItem(at: beganPoint), let movedIndexPath = collectionView.indexPathForItem(at: movedPoint){
                updateSelectedIndices = []
                for item in min(beganIndexPath.item, movedIndexPath.item)...max(beganIndexPath.item, movedIndexPath.item){
                    updateSelectedIndices.append(item)
                }
                selectedIndices = []
                if(updateBooleanValue){
                    for i in cachedSelectedIndices{
                        selectedIndices.append(i)
                    }
                    for i in updateSelectedIndices {
                        if( !selectedIndices.contains(i) ){
                            selectedIndices.append(i)
                        }
                    }
                } else{
                    for i in cachedSelectedIndices{
                        if( !updateSelectedIndices.contains(i) ){
                            selectedIndices.append(i)
                        }
                    }
                }
                collectionView.reloadData()
            }
        }
    }
}
~~~

次に、「タッチイベントが競合してスクロールが効かない」を解決させるために上の方、または下の方に指が移動した場合に自動的にスクロールさせるために必要な設定を書いていきます。[こちら](https://qiita.com/ddd503/items/ad0bb2397a7529064f70)の記事を参考に自動スクロールを実装しました。この辺はテキトーに改造したので、もっと効率の良い方があるような気がします。

~~~ViewController.swift(160行目近辺-200行目近辺)
extension ViewController{
// 自動スクロールを開始する
    private func startAutoScroll(duration: TimeInterval, direction: String) {
        if isNeedToStartTimer {
            // 表示されているCollectionViewのOffsetを取得
            var currentOffsetY = collectionView.contentOffset.y
            // 自動スクロールを終了させるかどうか
            shouldFinish = false
            autoScrollTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: true, block: { [weak self] (_) in
                guard let self = self else { return }
                DispatchQueue.main.async {
                    // item2つ分ずつスクロールさせる
                    switch direction {
                    case "up":
                        currentOffsetY = (currentOffsetY - 10 < 0) ? 0 : currentOffsetY - 10
                        self.shouldFinish = currentOffsetY == 0
                    case "down":
                        let highLimit = self.collectionView.contentSize.height - self.collectionView.bounds.size.height
                        currentOffsetY = (currentOffsetY + 10 > highLimit) ? highLimit : currentOffsetY + 10
                        self.shouldFinish = currentOffsetY == highLimit
                    default: break
                    }
                    UIView.animate(withDuration: duration, animations: {
                        self.collectionView.setContentOffset(CGPoint(x: 0, y: currentOffsetY), animated: false)
                    }, completion: { _ in
                        if self.shouldFinish { self.stopAutoScrollIfNeeded() }
                    })
                }
            })
        }
        isNeedToStartTimer = false
    }

    // 自動スクロールを停止する
    private func stopAutoScrollIfNeeded() {
        DispatchQueue.main.async {
            self.isNeedToStartTimer = true
            self.shouldFinish = true
            self.view.layer.removeAllAnimations()
            self.autoScrollTimer.invalidate()
        }
    }
}
~~~

最後に、ViewDidAppearで画面等のサイズを取得し、
スクロールを開始する高さの設定とセルサイズの設定をします。
scrollStartHeightTop変数とscrollStartHeightBottom変数がスクロールを開始する高さで、cellSize変数がセルの大きさの変数です。

~~~ViewController.swift(45行目付近)
    override func viewDidAppear(_ animated: Bool) {
        scrollStartHeightTop = self.view.safeAreaInsets.top + 50
        scrollStartHeightBottom = scrollStartHeightTop + collectionView.frame.height - 100
        let width = ( self.view.frame.width - 9 * (numOfRow + 1) ) / numOfRow
        cellSize = CGSize(width: width, height: width)
        collectionView.reloadData()
    }
~~~